mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-11 18:01:14 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ff7c830ca | ||
|
|
0e690184e7 |
@@ -1,198 +1,73 @@
|
||||
name: Check Cache
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
os:
|
||||
required: true
|
||||
type: string
|
||||
arch:
|
||||
required: false
|
||||
type: string
|
||||
compiler:
|
||||
required: false
|
||||
type: string
|
||||
default: msvc
|
||||
build-deps-only:
|
||||
required: false
|
||||
type: boolean
|
||||
force-build:
|
||||
required: false
|
||||
type: boolean
|
||||
|
||||
# Read by scripts/ci_deps_cache.sh.
|
||||
env:
|
||||
REPO: ${{ github.repository }}
|
||||
WORKFLOW_REF: ${{ github.workflow_ref }}
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
|
||||
jobs:
|
||||
check_cache: # determines if there is a cache and outputs variables used in caching process
|
||||
name: Check Cache
|
||||
# Hosted macOS runners are scarce, so their lookup runs on Linux.
|
||||
runs-on: ${{ startsWith(inputs.os, 'macos-') && 'ubuntu-24.04' || inputs.os }}
|
||||
outputs:
|
||||
cache-key: ${{ steps.set_outputs.outputs.cache-key }}
|
||||
cache-path: ${{ steps.set_outputs.outputs.cache-path }}
|
||||
valid-cache: ${{ steps.cache_deps.outputs.cache-hit }}
|
||||
macos-path: ${{ steps.macos_path.outputs.path }}
|
||||
key-inputs: ${{ steps.wait_check.outputs.key-inputs }}
|
||||
wait-for: ${{ steps.wait_check.outputs.wait-for }}
|
||||
wait-since: ${{ steps.wait_check.outputs.wait-since }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
lfs: 'false'
|
||||
# The wait check compares the merge commit with its first parent.
|
||||
fetch-depth: ${{ startsWith(inputs.os, 'macos-') && 2 || 1 }}
|
||||
# Fetches only deps/, which the key hashes, and the wait script.
|
||||
sparse-checkout: |
|
||||
/deps/
|
||||
/scripts/ci_deps_cache.sh
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: set outputs
|
||||
id: set_outputs
|
||||
env:
|
||||
# Anything that changes how the tree is built belongs in the key, or a job
|
||||
# restores one it cannot use. Linux amd64 passes no arch deliberately, so
|
||||
# 'linux-clang' keeps the cache it already has.
|
||||
cache-os: ${{ (runner.os == 'macOS' || startsWith(inputs.os, 'macos-')) && format('{0}-{1}', inputs.os, inputs.arch) || (runner.os == 'Windows' && format('windows-{0}-{1}', inputs.arch, inputs.compiler) || format('linux-clang{0}', inputs.arch && format('-{0}', inputs.arch) || '')) }}
|
||||
# The Windows ARM64 deps build in build-arm64, all others under build;
|
||||
# build_deps.yml and build_orca.yml pass the Windows directory to build_win.bat.
|
||||
dep-folder-name: ${{ (runner.os == 'macOS' || startsWith(inputs.os, 'macos-')) && format('/{0}', inputs.arch) || (runner.os == 'Windows' && inputs.arch == 'arm64') && '-arm64/OrcaSlicer_dep' || '/OrcaSlicer_dep' }}
|
||||
output-cmd: ${{ runner.os == 'Windows' && '$env:GITHUB_OUTPUT' || '"$GITHUB_OUTPUT"'}}
|
||||
run: |
|
||||
echo cache-key=${{ env.cache-os }}-cache-orcaslicer_deps-build-${{ hashFiles('deps/**') }} >> ${{ env.output-cmd }}
|
||||
# Relative to the workspace, which differs from the build's when this job runs on Linux.
|
||||
echo cache-path=deps/build${{ env.dep-folder-name }} >> ${{ env.output-cmd }}
|
||||
|
||||
# actions/cache matches the path too, so a Linux lookup for macOS uses the
|
||||
# workspace of a hosted macOS job.
|
||||
- name: macOS cache path
|
||||
id: macos_path
|
||||
if: ${{ startsWith(inputs.os, 'macos-') }}
|
||||
run: echo "path=/Users/runner/work/${GITHUB_REPOSITORY#*/}/${GITHUB_REPOSITORY#*/}/${{ steps.set_outputs.outputs.cache-path }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: load cache
|
||||
id: cache_deps
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ${{ steps.macos_path.outputs.path || format('{0}/{1}', github.workspace, steps.set_outputs.outputs.cache-path) }}
|
||||
key: ${{ steps.set_outputs.outputs.cache-key }}
|
||||
lookup-only: true
|
||||
|
||||
# A pull request that misses the cache can wait for a base branch build of the same deps.
|
||||
- name: check for a base branch build to wait for
|
||||
id: wait_check
|
||||
if: ${{ startsWith(inputs.os, 'macos-') && github.event_name == 'pull_request' && steps.cache_deps.outputs.cache-hit != 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: bash scripts/ci_deps_cache.sh check
|
||||
|
||||
wait_for_deps:
|
||||
name: Wait for base branch deps
|
||||
needs: [check_cache]
|
||||
if: ${{ needs.check_cache.outputs.wait-for != '' }}
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
WAIT_MINUTES: 120
|
||||
# Must exceed WAIT_MINUTES.
|
||||
timeout-minutes: 130
|
||||
# Waiting runs queue here, and only the first one holds a runner.
|
||||
concurrency:
|
||||
group: deps-wait-${{ github.base_ref }}-${{ needs.check_cache.outputs.cache-key }}
|
||||
queue: max
|
||||
outputs:
|
||||
hit: ${{ steps.lookup.outputs.cache-hit }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
sparse-checkout: scripts/ci_deps_cache.sh
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: wait for the deps cache
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
KEY: ${{ needs.check_cache.outputs.cache-key }}
|
||||
KEY_INPUTS: ${{ needs.check_cache.outputs.key-inputs }}
|
||||
WAIT_FOR: ${{ needs.check_cache.outputs.wait-for }}
|
||||
WAIT_SINCE: ${{ needs.check_cache.outputs.wait-since }}
|
||||
ARCH: ${{ inputs.arch }}
|
||||
run: bash scripts/ci_deps_cache.sh wait
|
||||
|
||||
- name: load cache
|
||||
id: lookup
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ${{ needs.check_cache.outputs.macos-path }}
|
||||
key: ${{ needs.check_cache.outputs.cache-key }}
|
||||
lookup-only: true
|
||||
|
||||
# Hosted macOS runners are scarce, so a pull request's macOS build waits until
|
||||
# one is free that push and nightly builds do not need. See the script.
|
||||
macos_admission:
|
||||
name: Wait for a macOS runner
|
||||
needs: [check_cache, wait_for_deps]
|
||||
if: ${{ !cancelled() && needs.check_cache.result == 'success' && startsWith(inputs.os, 'macos-') && github.event_name == 'pull_request' && !contains(github.event.pull_request.labels.*.name, 'macos-priority') }}
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
WAIT_MINUTES: 240
|
||||
PRIORITY: false
|
||||
# Must exceed WAIT_MINUTES.
|
||||
timeout-minutes: 250
|
||||
# One line for every arch of every pull request, and only its first job polls.
|
||||
concurrency:
|
||||
group: macos-admission
|
||||
queue: max
|
||||
steps: &macos_admission_steps
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
sparse-checkout: scripts/ci_macos_admission.py
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: wait for a macOS runner
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
MACOS_RUNNER_LIMIT: ${{ vars.MACOS_RUNNER_LIMIT }}
|
||||
# -u, or the log shows nothing until the wait ends.
|
||||
run: python3 -u scripts/ci_macos_admission.py
|
||||
|
||||
# The macos-priority label, when the run starts, puts a pull request in its own
|
||||
# line, which the normal one yields to. The names are fixed, because a skipped
|
||||
# job is listed under its unevaluated name, and the script matches both.
|
||||
macos_admission_priority:
|
||||
name: Wait for a macOS runner (priority)
|
||||
needs: [check_cache, wait_for_deps]
|
||||
if: ${{ !cancelled() && needs.check_cache.result == 'success' && startsWith(inputs.os, 'macos-') && github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'macos-priority') }}
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
WAIT_MINUTES: 240
|
||||
PRIORITY: true
|
||||
# Must exceed WAIT_MINUTES.
|
||||
timeout-minutes: 250
|
||||
concurrency:
|
||||
group: macos-admission-priority
|
||||
queue: max
|
||||
steps: *macos_admission_steps
|
||||
|
||||
build_deps: # call next step
|
||||
name: Build Deps
|
||||
needs: [check_cache, wait_for_deps, macos_admission, macos_admission_priority]
|
||||
# A failed wait counts as no cache, so the deps are built here. A failed
|
||||
# admission still builds rather than skip macOS.
|
||||
if: ${{ !cancelled() && needs.check_cache.result == 'success' }}
|
||||
uses: ./.github/workflows/build_deps.yml
|
||||
with:
|
||||
cache-key: ${{ needs.check_cache.outputs.cache-key }}
|
||||
cache-path: ${{ needs.check_cache.outputs.cache-path }}
|
||||
valid-cache: ${{ needs.check_cache.outputs.valid-cache == 'true' || needs.wait_for_deps.outputs.hit == 'true' }}
|
||||
os: ${{ inputs.os }}
|
||||
arch: ${{ inputs.arch }}
|
||||
compiler: ${{ inputs.compiler }}
|
||||
build-deps-only: ${{ inputs.build-deps-only }}
|
||||
force-build: ${{ inputs.force-build }}
|
||||
secrets: inherit
|
||||
name: Check Cache
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
os:
|
||||
required: true
|
||||
type: string
|
||||
arch:
|
||||
required: false
|
||||
type: string
|
||||
compiler:
|
||||
required: false
|
||||
type: string
|
||||
default: msvc
|
||||
build-deps-only:
|
||||
required: false
|
||||
type: boolean
|
||||
force-build:
|
||||
required: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
check_cache: # determines if there is a cache and outputs variables used in caching process
|
||||
name: Check Cache
|
||||
runs-on: ${{ inputs.os }}
|
||||
outputs:
|
||||
cache-key: ${{ steps.set_outputs.outputs.cache-key }}
|
||||
cache-path: ${{ steps.set_outputs.outputs.cache-path }}
|
||||
valid-cache: ${{ steps.cache_deps.outputs.cache-hit }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
lfs: 'false'
|
||||
|
||||
- name: set outputs
|
||||
id: set_outputs
|
||||
env:
|
||||
# Anything that changes how the tree is built belongs in the key, or a job
|
||||
# restores one it cannot use. Linux amd64 passes no arch deliberately, so
|
||||
# 'linux-clang' keeps the cache it already has.
|
||||
cache-os: ${{ runner.os == 'macOS' && format('{0}-{1}', inputs.os, inputs.arch) || (runner.os == 'Windows' && format('windows-{0}-{1}', inputs.arch, inputs.compiler) || format('linux-clang{0}', inputs.arch && format('-{0}', inputs.arch) || '')) }}
|
||||
# The Windows ARM64 deps build in build-arm64, all others under build;
|
||||
# build_deps.yml and build_orca.yml pass the Windows directory to build_win.bat.
|
||||
dep-folder-name: ${{ runner.os == 'macOS' && format('/{0}', inputs.arch) || (runner.os == 'Windows' && inputs.arch == 'arm64') && '-arm64/OrcaSlicer_dep' || '/OrcaSlicer_dep' }}
|
||||
output-cmd: ${{ runner.os == 'Windows' && '$env:GITHUB_OUTPUT' || '"$GITHUB_OUTPUT"'}}
|
||||
run: |
|
||||
echo cache-key=${{ env.cache-os }}-cache-orcaslicer_deps-build-${{ hashFiles('deps/**') }} >> ${{ env.output-cmd }}
|
||||
echo cache-path=${{ github.workspace }}/deps/build${{ env.dep-folder-name }} >> ${{ env.output-cmd }}
|
||||
|
||||
- name: load cache
|
||||
id: cache_deps
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ${{ steps.set_outputs.outputs.cache-path }}
|
||||
key: ${{ steps.set_outputs.outputs.cache-key }}
|
||||
lookup-only: true
|
||||
|
||||
build_deps: # call next step
|
||||
name: Build Deps
|
||||
needs: [check_cache]
|
||||
uses: ./.github/workflows/build_deps.yml
|
||||
with:
|
||||
cache-key: ${{ needs.check_cache.outputs.cache-key }}
|
||||
cache-path: ${{ needs.check_cache.outputs.cache-path }}
|
||||
valid-cache: ${{ needs.check_cache.outputs.valid-cache == 'true' }}
|
||||
os: ${{ inputs.os }}
|
||||
arch: ${{ inputs.arch }}
|
||||
compiler: ${{ inputs.compiler }}
|
||||
build-deps-only: ${{ inputs.build-deps-only }}
|
||||
force-build: ${{ inputs.force-build }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
- name: load cached deps
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ${{ github.workspace }}/${{ inputs.cache-path }}
|
||||
path: ${{ inputs.cache-path }}
|
||||
key: ${{ inputs.cache-key }}
|
||||
|
||||
- uses: lukka/get-cmake@latest
|
||||
@@ -122,7 +122,7 @@ jobs:
|
||||
choco install strawberryperl
|
||||
}
|
||||
# cache-path is the install directory inside the deps build directory.
|
||||
$deps = (Split-Path "${{ github.workspace }}/${{ inputs.cache-path }}").Replace('\', '/')
|
||||
$deps = (Split-Path "${{ inputs.cache-path }}").Replace('\', '/')
|
||||
# -l compiles with Visual Studio's clang-cl and -x builds with Ninja; --msvc --msbuild is cl under the Visual Studio generator.
|
||||
$flags = if ("${{ inputs.compiler }}" -eq "clang") { "-l", "-x" } else { "--msvc", "--msbuild" }
|
||||
.\build_win.bat -d --arch ${{ inputs.arch }} --deps-dir $deps @flags
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
if: ${{ !(runner.os == 'macOS' && inputs.macos-combine-only) }}
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ${{ github.workspace }}/${{ inputs.cache-path }}
|
||||
path: ${{ inputs.cache-path }}
|
||||
key: ${{ inputs.cache-key }}
|
||||
fail-on-cache-miss: true
|
||||
|
||||
@@ -467,7 +467,7 @@ jobs:
|
||||
# --tests builds the unit tests too; the unit_tests_windows_* jobs run them.
|
||||
run: |
|
||||
# cache-path is the install directory inside the deps build directory.
|
||||
$deps = (Split-Path "${{ github.workspace }}/${{ inputs.cache-path }}").Replace('\', '/')
|
||||
$deps = (Split-Path "${{ inputs.cache-path }}").Replace('\', '/')
|
||||
# -l compiles with Visual Studio's clang-cl and -x builds with Ninja; --msvc --msbuild is cl under the Visual Studio generator.
|
||||
$flags = if ("${{ inputs.compiler }}" -eq "clang") { "-l", "-x" } else { "--msvc", "--msbuild" }
|
||||
.\build_win.bat -s --tests -i --arch ${{ inputs.arch }} --build-dir $env:BUILD_DIR --deps-dir $deps @flags
|
||||
|
||||
@@ -16,6 +16,8 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# Listing caches while waiting for this PR's deps cache.
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
@@ -35,7 +37,7 @@ jobs:
|
||||
# The PR merge commit plus its first parent, the base it is diffed against.
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Look for changed C++ files
|
||||
- name: Look for changed files
|
||||
id: changes
|
||||
run: |
|
||||
if git -c core.quotePath=false diff --name-only HEAD^1 -- src tests | grep -qE '\.(cpp|cc|cxx|hpp|h|hxx)$'; then
|
||||
@@ -43,18 +45,68 @@ jobs:
|
||||
else
|
||||
echo "No C++ changes under src/ or tests/."
|
||||
fi
|
||||
if ! git diff --quiet HEAD^1 -- deps; then
|
||||
echo "deps=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "deps-key=linux-clang-cache-orcaslicer_deps-build-${{ hashFiles('deps/**') }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Parsing needs the dependency headers, not a build of this PR's deps/, so
|
||||
# a PR that changes deps/ falls back to the newest cache main has.
|
||||
# Parsing needs the dependency headers, not a build of this PR's deps/.
|
||||
# Without an exact match the job falls back to the newest cache main has,
|
||||
# unless the PR changes deps/: its code may need the new headers, so the
|
||||
# job waits for the cache this PR's Linux Build Deps job saves.
|
||||
- name: Restore cached deps
|
||||
id: deps_cache
|
||||
if: steps.changes.outputs.cpp == 'true'
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: ${{ github.workspace }}/deps/build/OrcaSlicer_dep
|
||||
key: linux-clang-cache-orcaslicer_deps-build-${{ hashFiles('deps/**') }}
|
||||
key: ${{ steps.changes.outputs.deps-key }}
|
||||
|
||||
- name: Restore the newest cached deps
|
||||
if: steps.changes.outputs.cpp == 'true' && steps.deps_cache.outputs.cache-hit != 'true' && steps.changes.outputs.deps != 'true'
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: ${{ github.workspace }}/deps/build/OrcaSlicer_dep
|
||||
key: ${{ steps.changes.outputs.deps-key }}
|
||||
restore-keys: linux-clang-cache-orcaslicer_deps-build-
|
||||
fail-on-cache-miss: true
|
||||
|
||||
# Linux Build Deps takes about 35 minutes on GitHub-hosted runners. A
|
||||
# failing lookup is retried a few times, then fails the job instead of
|
||||
# waiting out the deadline.
|
||||
- name: Wait for this PR's deps cache
|
||||
if: steps.changes.outputs.cpp == 'true' && steps.deps_cache.outputs.cache-hit != 'true' && steps.changes.outputs.deps == 'true'
|
||||
timeout-minutes: 100
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
KEY: ${{ steps.changes.outputs.deps-key }}
|
||||
run: |
|
||||
deadline=$((SECONDS + 90 * 60))
|
||||
failures=0
|
||||
while true; do
|
||||
if found=$(gh cache list --repo "$GITHUB_REPOSITORY" --ref "$GITHUB_REF" --key "$KEY" --json key --jq ".[] | select(.key == \"$KEY\") | .key"); then
|
||||
[ -n "$found" ] && break
|
||||
failures=0
|
||||
elif [ $((++failures)) -ge 3 ]; then
|
||||
echo "::error::Listing caches failed 3 times in a row."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$SECONDS" -ge "$deadline" ]; then
|
||||
echo "::error::No deps cache $KEY after 90 minutes. Check the Linux Build Deps job, then rerun clang-tidy."
|
||||
exit 1
|
||||
fi
|
||||
echo "Waiting for $KEY..."
|
||||
sleep 60
|
||||
done
|
||||
|
||||
- name: Restore this PR's deps cache
|
||||
if: steps.changes.outputs.cpp == 'true' && steps.deps_cache.outputs.cache-hit != 'true' && steps.changes.outputs.deps == 'true'
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: ${{ github.workspace }}/deps/build/OrcaSlicer_dep
|
||||
key: ${{ steps.changes.outputs.deps-key }}
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Apt-Install Dependencies
|
||||
if: steps.changes.outputs.cpp == 'true' && !vars.SELF_HOSTED
|
||||
uses: ./.github/actions/apt-install-deps
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Lets a pull request's macOS deps build wait for a push build of the base
|
||||
# branch that builds the same deps, from a Linux job. `check` runs after a cache
|
||||
# miss and names a queued or running base build with the same deps/ and .github/
|
||||
# to wait for, unless the pull request changes either. `wait` waits until that
|
||||
# build saves KEY. actions/cache then confirms the entry can be restored.
|
||||
#
|
||||
# Environment: GH_TOKEN, REPO, WORKFLOW_REF and BASE_REF, plus KEY_INPUTS,
|
||||
# WAIT_FOR, WAIT_SINCE, WAIT_MINUTES, KEY and ARCH for `wait`.
|
||||
# Writes key-inputs, wait-for and wait-since to $GITHUB_OUTPUT.
|
||||
set -uo pipefail
|
||||
|
||||
workflow=${WORKFLOW_REF%%@*}
|
||||
workflow=${workflow##*/}
|
||||
|
||||
# Must match the job names in build_all.yml, build_check_cache.yml and build_deps.yml.
|
||||
check_job_name="build_macos_arch (${ARCH:-}) / Check Cache"
|
||||
deps_job_name="build_macos_arch (${ARCH:-}) / Build Deps / Build Deps"
|
||||
|
||||
out() { echo "$1" >> "${GITHUB_OUTPUT:-/dev/stdout}"; }
|
||||
|
||||
# The cache key depends on deps/ and on the workflows under .github/.
|
||||
local_key_inputs() { echo "$(git rev-parse "$1:.github") $(git rev-parse "$1:deps")"; }
|
||||
remote_key_inputs() {
|
||||
gh api "repos/$REPO/git/trees/$1" < /dev/null \
|
||||
--jq '[.tree[] | select(.path == ".github" or .path == "deps") | {key: .path, value: .sha}] | from_entries | "\(.[".github"]) \(.deps)"'
|
||||
}
|
||||
|
||||
# Prints the id of a push build of the base branch that has not finished, is not
|
||||
# one of the ids in $1, and has KEY_INPUTS.
|
||||
matching_base_build() {
|
||||
local status runs id sha
|
||||
runs=$(for status in in_progress queued pending waiting requested; do
|
||||
gh api -X GET "repos/$REPO/actions/workflows/$workflow/runs" \
|
||||
-f branch="$BASE_REF" -f event=push -f status="$status" -f per_page=10 < /dev/null \
|
||||
| jq -r '.workflow_runs[] | "\(.id) \(.head_sha)"'
|
||||
done)
|
||||
while read -r id sha; do
|
||||
[ -n "$id" ] || continue
|
||||
[[ " ${1:-} " == *" $id "* ]] && continue
|
||||
if [ "$(remote_key_inputs "$sha")" = "$KEY_INPUTS" ]; then
|
||||
echo "$id"
|
||||
return
|
||||
fi
|
||||
done <<< "$runs"
|
||||
}
|
||||
|
||||
check() {
|
||||
KEY_INPUTS=$(local_key_inputs HEAD)
|
||||
out "key-inputs=$KEY_INPUTS"
|
||||
if [ "$KEY_INPUTS" != "$(local_key_inputs HEAD^1)" ]; then
|
||||
echo "This pull request changes deps/ or .github/, so it builds its own deps."
|
||||
return
|
||||
fi
|
||||
local id
|
||||
id=$(matching_base_build)
|
||||
if [ -n "$id" ]; then
|
||||
echo "Build $id of $BASE_REF has the same deps/ and .github/. Waiting for its cache."
|
||||
out "wait-for=$id"
|
||||
out "wait-since=$(date +%s)"
|
||||
else
|
||||
echo "No build of $BASE_REF is building these deps."
|
||||
fi
|
||||
}
|
||||
|
||||
key_saved() {
|
||||
gh cache list -R "$REPO" --ref "refs/heads/$BASE_REF" --key "$KEY" --json key < /dev/null \
|
||||
| jq -e --arg key "$KEY" 'any(.[]; .key == $key)' > /dev/null
|
||||
}
|
||||
|
||||
wait_for() {
|
||||
# Counts the limit from the check, including time queued behind other waiting
|
||||
# pull requests.
|
||||
local id=$WAIT_FOR seen=$WAIT_FOR deadline=$((${WAIT_SINCE:-$(date +%s)} + WAIT_MINUTES * 60)) jobs job check warned=
|
||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
if key_saved; then
|
||||
echo "Found $KEY."
|
||||
return
|
||||
fi
|
||||
jobs=$(gh api -X GET "repos/$REPO/actions/runs/$id/jobs" -f filter=latest -f per_page=100 < /dev/null)
|
||||
job=$(jq -r --arg name "$deps_job_name" 'first(.jobs[] | select(.name == $name) | "\(.status) \(.conclusion)") // ""' <<< "$jobs")
|
||||
case "$job" in
|
||||
"completed success" | "completed skipped")
|
||||
# The new entry can take a moment to be listed, and a skipped job means
|
||||
# the build found the cache, possibly under the default branch.
|
||||
for _ in 1 2 3; do
|
||||
key_saved && { echo "Found $KEY."; return; }
|
||||
sleep 20
|
||||
done
|
||||
echo "Build $id of $BASE_REF finished its deps without saving $KEY."
|
||||
return ;;
|
||||
"completed cancelled") ;;
|
||||
completed*)
|
||||
echo "The deps job of build $id of $BASE_REF did not succeed."
|
||||
return ;;
|
||||
# The deps job exists only once the build's cache check has succeeded.
|
||||
"")
|
||||
case "$(gh api "repos/$REPO/actions/runs/$id" --jq '"\(.status) \(.conclusion)"' < /dev/null)" in
|
||||
"completed cancelled") ;;
|
||||
completed*)
|
||||
check=$(jq -r --arg name "$check_job_name" 'first(.jobs[] | select(.name == $name) | .conclusion) // ""' <<< "$jobs")
|
||||
if [ -z "$warned" ] && { [ -z "$check" ] || [ "$check" = success ]; }; then
|
||||
echo "::warning title=Deps wait::Build $id of $BASE_REF has no job named \"$deps_job_name\". If the build jobs were renamed, update scripts/ci_deps_cache.sh. This does not affect this pull request."
|
||||
warned=1
|
||||
fi ;;
|
||||
*)
|
||||
sleep 120
|
||||
continue ;;
|
||||
esac ;;
|
||||
*)
|
||||
sleep 120
|
||||
continue ;;
|
||||
esac
|
||||
# Follows another unfinished build with the same key inputs, such as the
|
||||
# push that replaced a cancelled one.
|
||||
id=$(matching_base_build "$seen")
|
||||
[ -n "$id" ] || break
|
||||
seen="$seen $id"
|
||||
echo "Following build $id of $BASE_REF."
|
||||
done
|
||||
echo "No build of $BASE_REF saved $KEY."
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
check) check ;;
|
||||
wait) wait_for ;;
|
||||
*) echo "usage: $0 check|wait" >&2; exit 2 ;;
|
||||
esac
|
||||
@@ -1,269 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Holds a pull request's macOS build until a hosted macOS runner is free for it.
|
||||
|
||||
Runs from a Linux job in build_check_cache.yml, once per macOS arch, in a single
|
||||
repo-wide line (a concurrency group with queue: max), so only the job at the
|
||||
front of the line polls. It lets its arch in when the runners every active Build
|
||||
all run holds or still needs, plus one for this arch, fit in MACOS_RUNNER_LIMIT:
|
||||
|
||||
- A push, nightly or manual run holds RESERVE runners until its macOS work is
|
||||
done. The jobs API lists only the jobs a run has reached, so what it still
|
||||
needs cannot be counted and is reserved instead.
|
||||
- A pull request run holds one runner for each arch let in until that arch's
|
||||
app build is done, and none after. A run holding a runner while it waits for
|
||||
its other arch would deadlock: the other arch can sit in the line behind a
|
||||
pull request that waits for that runner.
|
||||
- A run holds at least the macOS jobs it has queued or running.
|
||||
|
||||
A pull request's universal build and tests skip the line and are not held for
|
||||
in advance. They take minutes, and holding two runners for them through the
|
||||
slower arch's build left runners idle while other builds waited.
|
||||
- In the minutes around the nightly's cron time, RESERVE runners are held for it
|
||||
until its run appears.
|
||||
|
||||
A pull request labelled macos-priority when its run starts waits in a separate
|
||||
line under the same rule, and the normal line counts every priority arch still
|
||||
waiting as holding a runner, so the next free runners go to it.
|
||||
|
||||
Any API error repeated FAILURES_BEFORE_ADMIT times, and the WAIT_MINUTES limit,
|
||||
let the arch in, so a fault here never blocks pull requests.
|
||||
|
||||
Environment: GH_TOKEN, REPO, WORKFLOW_REF, GITHUB_RUN_ID, and optionally PRIORITY,
|
||||
MACOS_RUNNER_LIMIT, WAIT_MINUTES, POLL_SECONDS and GITHUB_API_URL.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
# A push, nightly or manual run builds arm64 and x86_64 at the same time, then
|
||||
# the universal build and the macOS tests at the same time.
|
||||
RESERVE = 2
|
||||
|
||||
# Must match the job names in build_all.yml and build_check_cache.yml.
|
||||
ARCH_PREFIX = "build_macos_arch ("
|
||||
GATE = "Wait for a macOS runner"
|
||||
PRIORITY_GATE = GATE + " (priority)"
|
||||
FINAL_JOBS = {"Build macOS Universal", "macOS arm64"}
|
||||
# Must match the cron in build_all.yml.
|
||||
NIGHTLY_UTC = datetime.time(2, 15)
|
||||
NIGHTLY_REPO = "OrcaSlicer/OrcaSlicer"
|
||||
NIGHTLY_LEAD = datetime.timedelta(minutes=10)
|
||||
NIGHTLY_GRACE = datetime.timedelta(minutes=45)
|
||||
|
||||
ACTIVE = {"queued", "in_progress", "waiting", "pending", "requested"}
|
||||
# Runs created this recently are listed whatever their status.
|
||||
RECENT = datetime.timedelta(hours=24)
|
||||
FAILURES_BEFORE_ADMIT = 3
|
||||
|
||||
|
||||
def is_macos(job):
|
||||
return any(label.startswith("macos-") for label in job.get("labels") or [])
|
||||
|
||||
|
||||
def macos_done(jobs):
|
||||
"""True once the universal build and the macOS tests have finished or been skipped.
|
||||
Both start together when the arch builds finish."""
|
||||
# A skipped caller job is listed under its own name, a started one as "<name> / <job>".
|
||||
final = [job for job in jobs if job["name"].split(" / ")[0] in FINAL_JOBS]
|
||||
return bool(final) and all(job["status"] == "completed" for job in final)
|
||||
|
||||
|
||||
def gates(jobs, names=(GATE, PRIORITY_GATE)):
|
||||
return [job for job in jobs if job["name"].split(" / ")[-1] in names]
|
||||
|
||||
|
||||
def arch_of(job):
|
||||
name = job["name"]
|
||||
return name[len(ARCH_PREFIX):name.find(")")] if name.startswith(ARCH_PREFIX) else None
|
||||
|
||||
|
||||
def admitted_arches(jobs):
|
||||
return {arch_of(job) for job in gates(jobs) if job["conclusion"] == "success"}
|
||||
|
||||
|
||||
def arch_built(jobs, arch):
|
||||
"""True once the arch's app build finished, or one of its jobs failed or was cancelled."""
|
||||
own = [job for job in jobs if arch_of(job) == arch]
|
||||
return any(job["conclusion"] in ("failure", "cancelled") for job in own) or \
|
||||
any(" / Build OrcaSlicer" in job["name"] and job["status"] == "completed" for job in own)
|
||||
|
||||
|
||||
def priority_waiting(jobs):
|
||||
"""How many of the run's arches wait in the priority line."""
|
||||
return sum(1 for job in gates(jobs, (PRIORITY_GATE,)) if job["status"] != "completed")
|
||||
|
||||
|
||||
def run_demand(run, jobs, yield_to_priority=False):
|
||||
"""macOS runners a run holds or still needs. With yield_to_priority, each
|
||||
priority arch still waiting counts as holding one."""
|
||||
# A run with no jobs that is pending waits behind another run of its
|
||||
# concurrency group, which holds the runners for both.
|
||||
if not jobs and run["status"] in ("pending", "waiting"):
|
||||
return 0
|
||||
active = sum(1 for job in jobs if is_macos(job) and job["status"] in ACTIVE)
|
||||
if macos_done(jobs):
|
||||
return active
|
||||
if run["event"] != "pull_request":
|
||||
return max(active, RESERVE)
|
||||
held = sum(1 for arch in admitted_arches(jobs) if not arch_built(jobs, arch))
|
||||
if yield_to_priority:
|
||||
held += priority_waiting(jobs)
|
||||
return max(active, held)
|
||||
|
||||
|
||||
def nightly_window(now):
|
||||
"""The window around today's nightly cron time, as (start, end) in UTC."""
|
||||
cron = datetime.datetime.combine(now.date(), NIGHTLY_UTC, tzinfo=datetime.timezone.utc)
|
||||
return cron - NIGHTLY_LEAD, cron + NIGHTLY_GRACE
|
||||
|
||||
|
||||
def parse_time(value):
|
||||
return datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
|
||||
|
||||
class Api:
|
||||
def __init__(self, token, url="https://api.github.com"):
|
||||
self.token = token
|
||||
self.url = url.rstrip("/")
|
||||
# The token's hourly limit is shared by every workflow in the repository,
|
||||
# so what the last response says is left shows their use too.
|
||||
self.calls = 0
|
||||
self.remaining = self.limit = "?"
|
||||
|
||||
def get(self, path, **params):
|
||||
query = urllib.parse.urlencode(params)
|
||||
request = urllib.request.Request(f"{self.url}/{path}?{query}", headers={
|
||||
"Accept": "application/vnd.github+json",
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
})
|
||||
self.calls += 1
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
self.note_rate_limit(response.headers)
|
||||
return json.load(response)
|
||||
except urllib.error.HTTPError as error:
|
||||
# A rate-limited request fails, and its headers say so.
|
||||
self.note_rate_limit(error.headers)
|
||||
raise
|
||||
|
||||
def note_rate_limit(self, headers):
|
||||
self.remaining = headers.get("X-RateLimit-Remaining", "?")
|
||||
self.limit = headers.get("X-RateLimit-Limit", "?")
|
||||
|
||||
def usage(self, calls_before):
|
||||
return f" API calls this check: {self.calls - calls_before}, rate limit left: {self.remaining} of {self.limit}"
|
||||
|
||||
|
||||
def list_runs(api, repo, workflow, **params):
|
||||
runs, page = [], 1
|
||||
while True:
|
||||
body = api.get(f"repos/{repo}/actions/workflows/{workflow}/runs",
|
||||
per_page=100, page=page, **params)
|
||||
runs += body["workflow_runs"]
|
||||
if len(runs) >= body["total_count"] or not body["workflow_runs"]:
|
||||
return runs
|
||||
page += 1
|
||||
|
||||
|
||||
def latest_run(api, repo, workflow, **params):
|
||||
runs = api.get(f"repos/{repo}/actions/workflows/{workflow}/runs",
|
||||
per_page=1, **params)["workflow_runs"]
|
||||
return runs[0] if runs else None
|
||||
|
||||
|
||||
def measure(api, repo, workflow, run_id, now, priority=False):
|
||||
"""Total macOS runners held or needed, and one line per run that holds any.
|
||||
The priority line does not count the priority runs waiting behind it."""
|
||||
total, lines = 0, []
|
||||
# A run is listed as queued whenever one of its jobs waits for a runner, and
|
||||
# as pending whenever one waits in a concurrency group, so runs in any of these
|
||||
# can hold runners. A run that moves between the lists between the calls is
|
||||
# in neither, as one is whenever the job ahead of this one leaves the line, so
|
||||
# runs created recently are listed by creation time as well, and this run is
|
||||
# read directly.
|
||||
since = (now - RECENT).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
runs = {run["id"]: run for status in ("in_progress", "queued", "pending", "waiting")
|
||||
for run in list_runs(api, repo, workflow, status=status)}
|
||||
runs.update((run["id"], run) for run in list_runs(api, repo, workflow, created=f">={since}")
|
||||
if run["status"] != "completed")
|
||||
runs[run_id] = api.get(f"repos/{repo}/actions/runs/{run_id}")
|
||||
for run in runs.values():
|
||||
jobs = api.get(f"repos/{repo}/actions/runs/{run['id']}/jobs",
|
||||
filter="latest", per_page=100)["jobs"]
|
||||
own = run["id"] == run_id
|
||||
demand = run_demand(run, jobs, yield_to_priority=not priority and not own)
|
||||
if demand:
|
||||
total += demand
|
||||
waiting = ", priority, waiting" if priority_waiting(jobs) and not own else ""
|
||||
lines.append(f" {demand} run {run['id']} ({run['event']}, {run['head_branch']}{waiting})")
|
||||
|
||||
# build_all.yml runs the nightly only in the main repository.
|
||||
start, end = nightly_window(now)
|
||||
if repo == NIGHTLY_REPO and start <= now < end:
|
||||
last = latest_run(api, repo, workflow, event="schedule")
|
||||
if not last or parse_time(last["created_at"]) < start:
|
||||
total += RESERVE
|
||||
lines.append(f" {RESERVE} the nightly, due at {NIGHTLY_UTC:%H:%M} UTC")
|
||||
return total, lines
|
||||
|
||||
|
||||
def wait(measure_now, limit, wait_minutes, poll_seconds,
|
||||
clock=time.monotonic, sleep=time.sleep, log=print):
|
||||
"""Polls until this arch fits. Returns the reason it was let in."""
|
||||
deadline = clock() + wait_minutes * 60
|
||||
failures = 0
|
||||
while True:
|
||||
try:
|
||||
total, lines = measure_now()
|
||||
except (urllib.error.URLError, OSError, ValueError, KeyError, TypeError) as error:
|
||||
failures += 1
|
||||
log(f"::warning title=macOS admission::Could not read the queue ({error}).")
|
||||
if failures >= FAILURES_BEFORE_ADMIT:
|
||||
return "the queue could not be read"
|
||||
else:
|
||||
failures = 0
|
||||
log(f"{total} of {limit} macOS runners held or needed:")
|
||||
for line in lines:
|
||||
log(line)
|
||||
if total + 1 <= limit:
|
||||
return "a runner is free"
|
||||
if clock() + poll_seconds >= deadline:
|
||||
return f"it waited {wait_minutes} minutes"
|
||||
sleep(poll_seconds)
|
||||
|
||||
|
||||
def main():
|
||||
repo = os.environ["REPO"]
|
||||
workflow = os.environ["WORKFLOW_REF"].split("@")[0].rsplit("/", 1)[-1]
|
||||
api = Api(os.environ["GH_TOKEN"], os.environ.get("GITHUB_API_URL", "https://api.github.com"))
|
||||
run_id = int(os.environ["GITHUB_RUN_ID"])
|
||||
limit = int(os.environ.get("MACOS_RUNNER_LIMIT") or 5)
|
||||
|
||||
def measure_now():
|
||||
calls_before = api.calls
|
||||
try:
|
||||
total, lines = measure(api, repo, workflow, run_id, datetime.datetime.now(datetime.timezone.utc),
|
||||
priority=os.environ.get("PRIORITY") == "true")
|
||||
except Exception:
|
||||
print(api.usage(calls_before).strip())
|
||||
raise
|
||||
return total, lines + [api.usage(calls_before)]
|
||||
|
||||
reason = wait(
|
||||
measure_now,
|
||||
limit,
|
||||
wait_minutes=int(os.environ.get("WAIT_MINUTES") or 240),
|
||||
poll_seconds=int(os.environ.get("POLL_SECONDS") or 180),
|
||||
)
|
||||
print(f"Letting this macOS build in: {reason}.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,370 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for scripts/ci_macos_admission.py (stdlib unittest, no external deps).
|
||||
|
||||
Run from the repo root: python -m unittest discover -s scripts/tests -v
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
import urllib.error
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
|
||||
import ci_macos_admission as admission # noqa: E402
|
||||
|
||||
UTC = datetime.timezone.utc
|
||||
NOON = datetime.datetime(2026, 10, 10, 12, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def job(name, status="completed", conclusion="success", labels=("macos-15",)):
|
||||
return {"name": name, "status": status, "conclusion": conclusion, "labels": list(labels)}
|
||||
|
||||
|
||||
def gate(arch, status="completed", conclusion="success", priority=False):
|
||||
suffix = " (priority)" if priority else ""
|
||||
return job(f"build_macos_arch ({arch}) / Wait for a macOS runner{suffix}", status, conclusion,
|
||||
labels=("ubuntu-24.04",))
|
||||
|
||||
|
||||
def build(arch, status="in_progress"):
|
||||
return job(f"build_macos_arch ({arch}) / Build Deps / Build OrcaSlicer / Build OrcaSlicer",
|
||||
status, None if status != "completed" else "success")
|
||||
|
||||
|
||||
CHECK_CACHE = job("build_macos_arch (arm64) / Check Cache", labels=("ubuntu-24.04",))
|
||||
UNIVERSAL_DONE = job("Build macOS Universal / Build OrcaSlicer")
|
||||
TESTS_DONE = job("macOS arm64 / Unit Tests")
|
||||
PUSH = {"id": 1, "event": "push", "head_branch": "main", "status": "in_progress"}
|
||||
PR = {"id": 2, "event": "pull_request", "head_branch": "topic", "status": "in_progress"}
|
||||
|
||||
|
||||
class MacosDoneTest(unittest.TestCase):
|
||||
def test_not_done_before_the_final_jobs_are_listed(self):
|
||||
self.assertFalse(admission.macos_done([CHECK_CACHE, build("arm64", "completed")]))
|
||||
|
||||
def test_not_done_while_a_final_job_runs(self):
|
||||
running = job("macOS arm64 / Unit Tests", "in_progress", None)
|
||||
self.assertFalse(admission.macos_done([UNIVERSAL_DONE, running]))
|
||||
|
||||
def test_done_when_the_final_jobs_finished(self):
|
||||
self.assertTrue(admission.macos_done([build("arm64", "completed"), UNIVERSAL_DONE, TESTS_DONE]))
|
||||
|
||||
def test_done_when_the_final_jobs_were_skipped(self):
|
||||
# A skipped caller job is listed under its own name, without labels.
|
||||
skipped = [job("Build macOS Universal", conclusion="skipped", labels=()),
|
||||
job("macOS arm64", conclusion="skipped", labels=())]
|
||||
self.assertTrue(admission.macos_done(skipped))
|
||||
|
||||
|
||||
class RunDemandTest(unittest.TestCase):
|
||||
def test_push_reserves_before_its_macos_jobs_are_listed(self):
|
||||
self.assertEqual(admission.run_demand(PUSH, []), admission.RESERVE)
|
||||
self.assertEqual(admission.run_demand(PUSH, [CHECK_CACHE]), admission.RESERVE)
|
||||
|
||||
def test_push_keeps_its_reserve_between_stages(self):
|
||||
jobs = [build("arm64", "completed"), build("x86_64", "completed")]
|
||||
self.assertEqual(admission.run_demand(PUSH, jobs), admission.RESERVE)
|
||||
|
||||
def test_push_releases_when_macos_is_done(self):
|
||||
jobs = [build("arm64", "completed"), UNIVERSAL_DONE, TESTS_DONE]
|
||||
self.assertEqual(admission.run_demand(PUSH, jobs), 0)
|
||||
|
||||
def test_queued_jobs_count_as_busy(self):
|
||||
jobs = [build("arm64", "queued")]
|
||||
self.assertEqual(admission.run_demand(PR, jobs), 1)
|
||||
|
||||
def test_pull_request_holds_nothing_before_it_is_let_in(self):
|
||||
self.assertEqual(admission.run_demand(PR, [CHECK_CACHE, gate("arm64", "in_progress", None)]), 0)
|
||||
|
||||
def test_one_arch_let_in_holds_a_runner_until_it_is_built(self):
|
||||
# Including before its first macOS job is listed, and between its stages.
|
||||
self.assertEqual(admission.run_demand(PR, [gate("arm64")]), 1)
|
||||
self.assertEqual(admission.run_demand(PR, [gate("arm64"), build("arm64")]), 1)
|
||||
|
||||
def test_one_arch_let_in_holds_nothing_once_it_is_built(self):
|
||||
# Its other arch may be in the line behind a pull request waiting for this
|
||||
# runner, so holding it would deadlock.
|
||||
jobs = [gate("arm64"), build("arm64", "completed"), gate("x86_64", "pending", None)]
|
||||
self.assertEqual(admission.run_demand(PR, jobs), 0)
|
||||
|
||||
def test_one_arch_let_in_holds_nothing_once_it_failed(self):
|
||||
failed = job("build_macos_arch (arm64) / Build Deps / Build Deps", conclusion="failure")
|
||||
self.assertEqual(admission.run_demand(PR, [gate("arm64"), failed]), 0)
|
||||
|
||||
def test_both_arches_let_in_hold_only_what_still_builds(self):
|
||||
# The universal build and tests are not held for in advance.
|
||||
jobs = [gate("arm64"), gate("x86_64"), build("arm64", "completed"), build("x86_64")]
|
||||
self.assertEqual(admission.run_demand(PR, jobs), 1)
|
||||
|
||||
def test_universal_build_and_tests_count_once_queued(self):
|
||||
built = [gate("arm64"), gate("x86_64"), build("arm64", "completed"), build("x86_64", "completed")]
|
||||
self.assertEqual(admission.run_demand(PR, built), 0)
|
||||
queued = [job("Build macOS Universal / Build OrcaSlicer", "queued", None),
|
||||
job("macOS arm64 / Unit Tests", "in_progress", None)]
|
||||
self.assertEqual(admission.run_demand(PR, built + queued), 2)
|
||||
|
||||
def test_pull_request_releases_when_macos_is_done(self):
|
||||
jobs = [gate("arm64"), gate("x86_64"), UNIVERSAL_DONE, TESTS_DONE]
|
||||
self.assertEqual(admission.run_demand(PR, jobs), 0)
|
||||
|
||||
def test_pull_request_without_the_line_holds_what_it_runs(self):
|
||||
# A run started from a workflow without the line.
|
||||
self.assertEqual(admission.run_demand(PR, [build("arm64"), build("x86_64", "queued")]), 2)
|
||||
|
||||
def test_finished_and_non_macos_jobs_are_not_counted(self):
|
||||
jobs = [build("arm64", "completed"), CHECK_CACHE,
|
||||
job("build_linux (ubuntu-24.04) / Check Cache", "in_progress", None, ("ubuntu-24.04",))]
|
||||
self.assertEqual(admission.run_demand(PR, jobs), 0)
|
||||
|
||||
|
||||
class PriorityTest(unittest.TestCase):
|
||||
WAITING = [gate("arm64", "in_progress", None, priority=True), gate("x86_64", "pending", None, priority=True)]
|
||||
|
||||
def test_normal_line_yields_to_a_waiting_priority_run(self):
|
||||
self.assertEqual(admission.run_demand(PR, self.WAITING, yield_to_priority=True), admission.RESERVE)
|
||||
|
||||
def test_priority_line_does_not_count_priority_runs_behind_it(self):
|
||||
self.assertEqual(admission.run_demand(PR, self.WAITING), 0)
|
||||
|
||||
def test_a_waiting_normal_run_is_not_yielded_to(self):
|
||||
waiting = [gate("arm64", "in_progress", None), gate("x86_64", "pending", None)]
|
||||
self.assertEqual(admission.run_demand(PR, waiting, yield_to_priority=True), 0)
|
||||
|
||||
def test_a_priority_run_half_let_in_still_counts_its_waiting_arch(self):
|
||||
jobs = [gate("arm64", priority=True), gate("x86_64", "pending", None, priority=True)]
|
||||
self.assertEqual(admission.run_demand(PR, jobs), 1)
|
||||
self.assertEqual(admission.run_demand(PR, jobs, yield_to_priority=True), 2)
|
||||
|
||||
def test_skipped_priority_jobs_of_other_platforms_are_not_waiting(self):
|
||||
jobs = [job("build_linux (ubuntu-24.04) / Wait for a macOS runner (priority)",
|
||||
conclusion="skipped", labels=("ubuntu-24.04",))]
|
||||
self.assertEqual(admission.run_demand(PR, jobs, yield_to_priority=True), 0)
|
||||
|
||||
def test_measure_from_each_line(self):
|
||||
api = FakeApi({("runs", "pending"): [dict(PR, status="pending")], jobs_path(2): self.WAITING})
|
||||
total, lines = admission.measure(api, "o/r", "build_all.yml", 99, NOON)
|
||||
self.assertEqual(total, admission.RESERVE)
|
||||
self.assertIn("priority, waiting", lines[0])
|
||||
total, _ = admission.measure(api, "o/r", "build_all.yml", 99, NOON, priority=True)
|
||||
self.assertEqual(total, 0)
|
||||
|
||||
def test_own_waiting_run_is_not_counted(self):
|
||||
api = FakeApi({("runs", "pending"): [dict(PR, status="pending")], jobs_path(2): self.WAITING})
|
||||
total, _ = admission.measure(api, "o/r", "build_all.yml", 2, NOON)
|
||||
self.assertEqual(total, 0)
|
||||
|
||||
|
||||
class FakeApi:
|
||||
"""Answers GET requests from a dict of path -> list of pages (or one body)."""
|
||||
|
||||
def __init__(self, responses):
|
||||
self.responses = responses
|
||||
self.calls = []
|
||||
|
||||
def get(self, path, **params):
|
||||
self.calls.append((path, params))
|
||||
if path.endswith("/runs") and ("status" in params or "created" in params):
|
||||
runs = self.responses.get(("runs", params.get("status", "recent")), [])
|
||||
start = (params["page"] - 1) * params["per_page"]
|
||||
return {"total_count": len(runs), "workflow_runs": runs[start:start + params["per_page"]]}
|
||||
if path.endswith("/runs"):
|
||||
return {"workflow_runs": self.responses.get(("runs", params.get("event")), [])[:1]}
|
||||
if path.endswith("/jobs"):
|
||||
return {"jobs": self.responses.get(path, [])}
|
||||
# A run read by its id, by default a pull request with no jobs listed.
|
||||
return self.responses.get(path, dict(PR, id=int(path.rsplit("/", 1)[-1])))
|
||||
|
||||
|
||||
def jobs_path(run_id):
|
||||
return f"repos/o/r/actions/runs/{run_id}/jobs"
|
||||
|
||||
|
||||
class MeasureTest(unittest.TestCase):
|
||||
def test_sums_in_progress_and_queued_runs(self):
|
||||
queued_pr = dict(PR, id=3, status="queued")
|
||||
api = FakeApi({
|
||||
("runs", "in_progress"): [PUSH, PR],
|
||||
("runs", "queued"): [queued_pr],
|
||||
jobs_path(1): [CHECK_CACHE],
|
||||
jobs_path(2): [gate("arm64"), build("arm64")],
|
||||
jobs_path(3): [gate("arm64"), gate("x86_64"), build("arm64", "queued")],
|
||||
})
|
||||
total, lines = admission.measure(api, "o/r", "build_all.yml", 99, NOON)
|
||||
self.assertEqual(total, admission.RESERVE + 1 + admission.RESERVE)
|
||||
self.assertEqual(len(lines), 3)
|
||||
|
||||
def test_counts_a_pending_run_that_holds_runners(self):
|
||||
# One of its jobs waits in a concurrency group while its macOS builds run.
|
||||
pending_pr = dict(PR, status="pending")
|
||||
api = FakeApi({("runs", "pending"): [pending_pr], jobs_path(2): [gate("arm64"), build("arm64")]})
|
||||
total, _ = admission.measure(api, "o/r", "build_all.yml", 99, NOON)
|
||||
self.assertEqual(total, 1)
|
||||
|
||||
def test_a_push_waiting_behind_another_holds_nothing(self):
|
||||
api = FakeApi({("runs", "pending"): [dict(PUSH, status="pending")], jobs_path(1): []})
|
||||
total, _ = admission.measure(api, "o/r", "build_all.yml", 99, NOON)
|
||||
self.assertEqual(total, 0)
|
||||
|
||||
def test_reads_every_page_of_runs(self):
|
||||
runs = [dict(PR, id=i) for i in range(150)]
|
||||
responses = {("runs", "in_progress"): runs}
|
||||
responses.update({jobs_path(i): [gate("arm64")] for i in range(150)})
|
||||
total, _ = admission.measure(FakeApi(responses), "o/r", "build_all.yml", 999, NOON)
|
||||
self.assertEqual(total, 150)
|
||||
|
||||
def test_reserves_for_the_nightly_until_its_run_appears(self):
|
||||
due = datetime.datetime(2026, 10, 10, 2, 10, tzinfo=UTC)
|
||||
yesterday = {"created_at": "2026-10-09T02:20:00Z"}
|
||||
total, lines = admission.measure(
|
||||
FakeApi({("runs", "schedule"): [yesterday]}), "OrcaSlicer/OrcaSlicer", "build_all.yml", 99, due)
|
||||
self.assertEqual(total, admission.RESERVE)
|
||||
self.assertIn("nightly", lines[0])
|
||||
|
||||
def test_no_nightly_reserve_once_its_run_exists(self):
|
||||
due = datetime.datetime(2026, 10, 10, 2, 30, tzinfo=UTC)
|
||||
today = {"created_at": "2026-10-10T02:21:00Z"}
|
||||
total, _ = admission.measure(
|
||||
FakeApi({("runs", "schedule"): [today]}), "OrcaSlicer/OrcaSlicer", "build_all.yml", 99, due)
|
||||
self.assertEqual(total, 0)
|
||||
|
||||
def test_no_nightly_reserve_in_a_fork(self):
|
||||
due = datetime.datetime(2026, 10, 10, 2, 10, tzinfo=UTC)
|
||||
total, _ = admission.measure(FakeApi({}), "fork/OrcaSlicer", "build_all.yml", 99, due)
|
||||
self.assertEqual(total, 0)
|
||||
|
||||
def test_counts_a_run_in_both_lists_once(self):
|
||||
api = FakeApi({
|
||||
("runs", "in_progress"): [PUSH],
|
||||
("runs", "queued"): [dict(PUSH, status="queued")],
|
||||
jobs_path(1): [],
|
||||
})
|
||||
total, _ = admission.measure(api, "o/r", "build_all.yml", 99, NOON)
|
||||
self.assertEqual(total, admission.RESERVE)
|
||||
|
||||
def test_counts_its_own_run(self):
|
||||
jobs = [gate("arm64"), build("arm64"), gate("x86_64", "in_progress", None)]
|
||||
api = FakeApi({("runs", "in_progress"): [PR], jobs_path(2): jobs})
|
||||
self.assertEqual(admission.measure(api, "o/r", "build_all.yml", 2, NOON)[0], 1)
|
||||
|
||||
def test_a_run_whose_other_arch_waits_behind_this_one_does_not_block_it(self):
|
||||
# The line is A-arm64, B-arm64, A-x86_64. A's arm64 is built and its
|
||||
# x86_64 is pending behind B, so A must not hold a runner B waits for.
|
||||
other = dict(PR, id=1, status="pending")
|
||||
api = FakeApi({
|
||||
("runs", "pending"): [other, dict(PR, status="pending")],
|
||||
jobs_path(1): [gate("arm64"), build("arm64", "completed"), gate("x86_64", "pending", None)],
|
||||
jobs_path(2): [gate("arm64", "in_progress", None)],
|
||||
})
|
||||
total, _ = admission.measure(api, "o/r", "build_all.yml", 2, NOON)
|
||||
self.assertEqual(total, 0)
|
||||
|
||||
def test_counts_its_own_run_when_no_status_list_has_it(self):
|
||||
# The run moves from pending to in_progress as its last gate leaves the
|
||||
# line, and is in neither list while that gate checks.
|
||||
jobs = [gate("x86_64"), build("x86_64", "queued"), gate("arm64", "in_progress", None)]
|
||||
api = FakeApi({jobs_path(2): jobs})
|
||||
self.assertEqual(admission.measure(api, "o/r", "build_all.yml", 2, NOON)[0], 1)
|
||||
|
||||
def test_counts_a_recent_run_that_no_status_list_has(self):
|
||||
api = FakeApi({("runs", "recent"): [PR], jobs_path(2): [gate("arm64"), build("arm64")]})
|
||||
self.assertEqual(admission.measure(api, "o/r", "build_all.yml", 99, NOON)[0], 1)
|
||||
|
||||
def test_lists_recent_runs_from_a_day_before(self):
|
||||
api = FakeApi({})
|
||||
admission.measure(api, "o/r", "build_all.yml", 99, NOON)
|
||||
self.assertIn(">=2026-10-09T12:00:00Z", [params.get("created") for _, params in api.calls])
|
||||
|
||||
def test_finished_recent_runs_are_not_read(self):
|
||||
done = dict(PUSH, status="completed")
|
||||
api = FakeApi({("runs", "recent"): [done]})
|
||||
total, _ = admission.measure(api, "o/r", "build_all.yml", 99, NOON)
|
||||
self.assertEqual(total, 0)
|
||||
self.assertNotIn(jobs_path(1), [path for path, _ in api.calls])
|
||||
|
||||
def test_no_nightly_lookup_outside_its_window(self):
|
||||
api = FakeApi({})
|
||||
admission.measure(api, "o/r", "build_all.yml", 99, NOON)
|
||||
self.assertFalse(any("event" in params for _, params in api.calls))
|
||||
|
||||
|
||||
class ApiTest(unittest.TestCase):
|
||||
class Response:
|
||||
def __init__(self, headers):
|
||||
self.headers = headers
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return b"{}"
|
||||
|
||||
def test_reports_calls_and_the_rate_limit_left(self):
|
||||
api = admission.Api("token")
|
||||
responses = [self.Response({}),
|
||||
self.Response({"X-RateLimit-Remaining": "812", "X-RateLimit-Limit": "1000"})]
|
||||
with mock.patch.object(admission.urllib.request, "urlopen", side_effect=responses):
|
||||
api.get("first")
|
||||
calls_before = api.calls
|
||||
api.get("second")
|
||||
self.assertEqual(api.usage(0), " API calls this check: 2, rate limit left: 812 of 1000")
|
||||
self.assertEqual(api.usage(calls_before), " API calls this check: 1, rate limit left: 812 of 1000")
|
||||
|
||||
def test_reads_the_rate_limit_from_a_failed_request(self):
|
||||
api = admission.Api("token")
|
||||
headers = {"X-RateLimit-Remaining": "0", "X-RateLimit-Limit": "1000"}
|
||||
error = urllib.error.HTTPError("url", 403, "Forbidden", headers, None)
|
||||
with mock.patch.object(admission.urllib.request, "urlopen", side_effect=error), \
|
||||
self.assertRaises(urllib.error.HTTPError):
|
||||
api.get("runs")
|
||||
self.assertEqual(api.usage(0), " API calls this check: 1, rate limit left: 0 of 1000")
|
||||
|
||||
|
||||
class WaitTest(unittest.TestCase):
|
||||
def run_wait(self, results, limit=5, wait_minutes=60, poll_seconds=120):
|
||||
results = iter(results)
|
||||
now = [0.0]
|
||||
|
||||
def measure_now():
|
||||
result = next(results)
|
||||
if isinstance(result, Exception):
|
||||
raise result
|
||||
return result, []
|
||||
|
||||
def sleep(seconds):
|
||||
now[0] += seconds
|
||||
|
||||
reason = admission.wait(measure_now, limit, wait_minutes, poll_seconds,
|
||||
clock=lambda: now[0], sleep=sleep, log=lambda _: None)
|
||||
return reason, now[0]
|
||||
|
||||
def test_lets_in_when_a_runner_fits(self):
|
||||
self.assertEqual(self.run_wait([4]), ("a runner is free", 0))
|
||||
|
||||
def test_waits_while_full(self):
|
||||
self.assertEqual(self.run_wait([5, 6, 4]), ("a runner is free", 240))
|
||||
|
||||
|
||||
def test_lets_in_after_repeated_api_errors(self):
|
||||
error = urllib.error.URLError("rate limited")
|
||||
reason, _ = self.run_wait([error, error, error])
|
||||
self.assertEqual(reason, "the queue could not be read")
|
||||
|
||||
def test_a_good_read_resets_the_error_count(self):
|
||||
error = urllib.error.URLError("rate limited")
|
||||
reason, _ = self.run_wait([error, error, 5, error, error, 3])
|
||||
self.assertEqual(reason, "a runner is free")
|
||||
|
||||
def test_lets_in_at_the_time_limit(self):
|
||||
reason, elapsed = self.run_wait([5] * 100, wait_minutes=10, poll_seconds=120)
|
||||
self.assertEqual(reason, "it waited 10 minutes")
|
||||
self.assertLess(elapsed, 600)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+27
-39
@@ -8968,16 +8968,6 @@ static float overhang_fan_overlap_threshold(int overhang_fan_threshold)
|
||||
}
|
||||
}
|
||||
|
||||
static bool overhang_fan_active(float overlap, int overhang_fan_threshold, ExtrusionRole role)
|
||||
{
|
||||
if (role == erBridgeInfill || role == erOverhangPerimeter)
|
||||
return true;
|
||||
if (overhang_fan_threshold == Overhang_threshold_none)
|
||||
return is_external_perimeter(role);
|
||||
const float overlap_threshold = overhang_fan_overlap_threshold(overhang_fan_threshold);
|
||||
return overlap_threshold >= 0.f && overlap <= overlap_threshold;
|
||||
}
|
||||
|
||||
std::string GCode::_extrude(const ExtrusionPath &path, const std::string &path_description, double speed)
|
||||
{
|
||||
std::string gcode;
|
||||
@@ -9336,10 +9326,7 @@ std::string GCode::_extrude(const ExtrusionPath &path, const std::string &path_d
|
||||
}
|
||||
}
|
||||
|
||||
bool requires_segmented_extrusion = false;
|
||||
// A uniform fan state can be represented by the normal path-wide marker. Only a
|
||||
// transition within the path requires point-by-point emission.
|
||||
bool path_overhang_fan_active = false;
|
||||
bool variable_speed = false;
|
||||
std::vector<ProcessedPoint> new_points {};
|
||||
|
||||
const bool need_overhang_detection = NOZZLE_CONFIG(enable_overhang_speed) ||
|
||||
@@ -9411,21 +9398,13 @@ std::string GCode::_extrude(const ExtrusionPath &path, const std::string &path_d
|
||||
ref_speed, speed, NOZZLE_CONFIG(slowdown_for_curled_perimeters),
|
||||
fan_overlap_threshold);
|
||||
}
|
||||
if (!NOZZLE_CONFIG(enable_overhang_speed))
|
||||
for (ProcessedPoint &point : new_points)
|
||||
point.speed = speed;
|
||||
requires_segmented_extrusion = std::any_of(new_points.begin(), new_points.end(),
|
||||
[speed](const ProcessedPoint &point) { return fabs(double(point.speed) - speed) > 1; }); // Ignore small speed variations (under 1mm/sec)
|
||||
if (FILAMENT_CONFIG(enable_overhang_bridge_fan) && m_enable_cooling_markers && fan_overlap_threshold >= 0.f &&
|
||||
!new_points.empty()) {
|
||||
path_overhang_fan_active = new_points.front().overlap <= fan_overlap_threshold;
|
||||
// An overhang-fan state change needs separate extrusion moves so the cooling buffer can insert
|
||||
// the corresponding fan command between them.
|
||||
requires_segmented_extrusion = requires_segmented_extrusion ||
|
||||
std::adjacent_find(new_points.begin(), new_points.end(), [fan_overlap_threshold](const ProcessedPoint &previous,
|
||||
const ProcessedPoint ¤t) {
|
||||
return (previous.overlap <= fan_overlap_threshold) != (current.overlap <= fan_overlap_threshold);
|
||||
}) != new_points.end();
|
||||
variable_speed = std::any_of(new_points.begin(), new_points.end(),
|
||||
[speed](const ProcessedPoint &p) { return fabs(double(p.speed) - speed) > 1; }); // Ignore small speed variations (under 1mm/sec)
|
||||
if (FILAMENT_CONFIG(enable_overhang_bridge_fan) && m_enable_cooling_markers) {
|
||||
if (!NOZZLE_CONFIG(enable_overhang_speed))
|
||||
for (ProcessedPoint &point : new_points)
|
||||
point.speed = speed;
|
||||
variable_speed = new_points.size() > 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9563,17 +9542,22 @@ std::string GCode::_extrude(const ExtrusionPath &path, const std::string &path_d
|
||||
auto overhang_fan_threshold = FILAMENT_CONFIG(overhang_fan_threshold);
|
||||
auto enable_overhang_bridge_fan = FILAMENT_CONFIG(enable_overhang_bridge_fan);
|
||||
|
||||
if (enable_overhang_bridge_fan &&
|
||||
(path.role() == erBridgeInfill || path.role() == erOverhangPerimeter ||
|
||||
(overhang_fan_threshold == Overhang_threshold_none && is_external_perimeter(path.role()))))
|
||||
path_overhang_fan_active = true;
|
||||
|
||||
// { "0%", Overhang_threshold_none },
|
||||
// { "10%", Overhang_threshold_1_4 },
|
||||
// { "25%", Overhang_threshold_2_4 },
|
||||
// { "50%", Overhang_threshold_3_4 },
|
||||
// { "75%", Overhang_threshold_4_4 },
|
||||
// { "95%", Overhang_threshold_bridge }
|
||||
auto check_overhang_fan = [&overhang_fan_threshold](float overlap, ExtrusionRole role) {
|
||||
if (role == erBridgeInfill || role == erOverhangPerimeter) { // ORCA: Split out bridge infill to internal and external to apply separate fan settings
|
||||
return true;
|
||||
}
|
||||
if (overhang_fan_threshold == Overhang_threshold_none)
|
||||
return is_external_perimeter(role);
|
||||
const float overlap_threshold = overhang_fan_overlap_threshold(overhang_fan_threshold);
|
||||
return overlap_threshold >= 0.f && overlap <= overlap_threshold;
|
||||
};
|
||||
|
||||
std::string comment;
|
||||
if (m_enable_cooling_markers) {
|
||||
comment = ";_EXTRUDE_SET_SPEED";
|
||||
@@ -9644,7 +9628,7 @@ std::string GCode::_extrude(const ExtrusionPath &path, const std::string &path_d
|
||||
}
|
||||
};
|
||||
|
||||
if (!requires_segmented_extrusion) {
|
||||
if (!variable_speed) {
|
||||
// F is mm per minute.
|
||||
if( (std::abs(writer().get_current_speed() - F) > EPSILON) || (std::abs(_mm3_per_mm - m_last_mm3_mm) > EPSILON) ){
|
||||
// ORCA: Adaptive PA code segment when adjusting PA within the same feature
|
||||
@@ -9694,7 +9678,11 @@ std::string GCode::_extrude(const ExtrusionPath &path, const std::string &path_d
|
||||
{
|
||||
if (m_enable_cooling_markers) {
|
||||
if (enable_overhang_bridge_fan) {
|
||||
append_role_based_fan_marker(erOverhangPerimeter, "_OVERHANG"sv, path_overhang_fan_active);
|
||||
// BBS: Overhang_threshold_none means Overhang_threshold_1_4 and forcing cooling for all external
|
||||
// perimeter
|
||||
append_role_based_fan_marker(erOverhangPerimeter, "_OVERHANG"sv,
|
||||
(overhang_fan_threshold == Overhang_threshold_none && is_external_perimeter(path.role())) ||
|
||||
(path.role() == erBridgeInfill || path.role() == erOverhangPerimeter)); // ORCA: Add support for separate internal bridge fan speed control
|
||||
|
||||
// ORCA: Add support for separate internal bridge fan speed control
|
||||
append_role_based_fan_marker(erInternalBridgeInfill, "_INTERNAL_BRIDGE"sv, path.role() == erInternalBridgeInfill);
|
||||
@@ -9844,7 +9832,7 @@ std::string GCode::_extrude(const ExtrusionPath &path, const std::string &path_d
|
||||
bool pre_fan_enabled = false;
|
||||
bool cur_fan_enabled = false;
|
||||
if( m_enable_cooling_markers && enable_overhang_bridge_fan)
|
||||
pre_fan_enabled = overhang_fan_active(new_points[0].overlap, overhang_fan_threshold, path.role());
|
||||
pre_fan_enabled = check_overhang_fan(new_points[0].overlap, path.role());
|
||||
|
||||
if(path.role() == erInternalBridgeInfill) // ORCA: Add support for separate internal bridge fan speed control
|
||||
pre_fan_enabled = true;
|
||||
@@ -9858,7 +9846,7 @@ std::string GCode::_extrude(const ExtrusionPath &path, const std::string &path_d
|
||||
tag_belt_band((pre_processed_point.p.x() + processed_point.p.x()) / 2, (pre_processed_point.p.y() + processed_point.p.y()) / 2);
|
||||
if (m_enable_cooling_markers) {
|
||||
if (enable_overhang_bridge_fan) {
|
||||
cur_fan_enabled = overhang_fan_active(processed_point.overlap, overhang_fan_threshold, path.role());
|
||||
cur_fan_enabled = check_overhang_fan(processed_point.overlap, path.role());
|
||||
append_role_based_fan_marker(erOverhangPerimeter, "_OVERHANG"sv, pre_fan_enabled && cur_fan_enabled);
|
||||
pre_fan_enabled = cur_fan_enabled;
|
||||
|
||||
@@ -9961,7 +9949,7 @@ std::string GCode::_extrude(const ExtrusionPath &path, const std::string &path_d
|
||||
m_writer.extrude_to_xyz(gcode, dest3d, dE * e_ratio, flow_description.empty() ? description : flow_description);
|
||||
}
|
||||
|
||||
// Inline farthest-point snapshot on the segmented emission path. Inert unless the
|
||||
// Inline farthest-point snapshot on the variable-speed emission path. Inert unless the
|
||||
// farthest-point subsystem is on, matching the other paths.
|
||||
check_and_insert_timelapse(processed_point.p.to_point());
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
#include "test_helpers.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstddef>
|
||||
#include "libslic3r/PrintConfig.hpp"
|
||||
#include "libslic3r/Config.hpp"
|
||||
#include "libslic3r/TriangleMesh.hpp"
|
||||
@@ -36,64 +35,6 @@ TEST_CASE("Cooling consumes its internal speed markers", "[Cooling]")
|
||||
CHECK(gcode.find(";_EXTRUDE_SET_SPEED") == std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("Arc fitting remains active with overhang fan control", "[Cooling][Regression]")
|
||||
{
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.set_deserialize_strict({
|
||||
{ "enable_arc_fitting", true },
|
||||
{ "enable_overhang_bridge_fan", true },
|
||||
{ "enable_overhang_speed", true },
|
||||
{ "initial_layer_print_height", 0.2 },
|
||||
{ "layer_height", 0.2 },
|
||||
{ "slow_down_for_layer_cooling", false },
|
||||
});
|
||||
config.set_key_value("fan_max_speed", new ConfigOptionFloats{20.0});
|
||||
config.set_key_value("fan_min_speed", new ConfigOptionFloats{20.0});
|
||||
config.set_key_value("overhang_fan_speed", new ConfigOptionInts{100});
|
||||
config.set_key_value("overhang_fan_threshold", new ConfigOptionEnumsGeneric{Overhang_threshold_2_4});
|
||||
config.set_key_value("layer_change_gcode", new ConfigOptionString{";TEST_LAYER=[layer_num]"});
|
||||
|
||||
const auto has_arc_after_first_layer = [](const std::string &gcode) {
|
||||
std::istringstream input(gcode);
|
||||
std::string line;
|
||||
size_t layers_seen = 0;
|
||||
while (std::getline(input, line)) {
|
||||
if (line.rfind(";TEST_LAYER=", 0) == 0)
|
||||
++layers_seen;
|
||||
else if (layers_seen > 1 && (line.rfind("G2 ", 0) == 0 || line.rfind("G3 ", 0) == 0))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const auto has_arc_with_overhang_fan = [](const std::string &gcode) {
|
||||
std::istringstream input(gcode);
|
||||
std::string line;
|
||||
bool overhang_fan_active = false;
|
||||
bool outer_wall = false;
|
||||
while (std::getline(input, line)) {
|
||||
if (line.rfind("M106 S255", 0) == 0)
|
||||
overhang_fan_active = true;
|
||||
else if (line.rfind("M106 S0", 0) == 0 || line.rfind("M107", 0) == 0)
|
||||
overhang_fan_active = false;
|
||||
else if (line.rfind(";TYPE:", 0) == 0)
|
||||
outer_wall = line == ";TYPE:Outer wall";
|
||||
else if (overhang_fan_active && outer_wall && (line.rfind("G2 ", 0) == 0 || line.rfind("G3 ", 0) == 0))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const std::string with_overhang_speed = slice({ make_cylinder(25.0, 10.0, 2.0 * PI / 72.0) }, config);
|
||||
CHECK(has_arc_after_first_layer(with_overhang_speed));
|
||||
|
||||
config.set_deserialize_strict({ { "enable_overhang_speed", false } });
|
||||
const std::string without_overhang_speed = slice({ make_cylinder(25.0, 10.0, 2.0 * PI / 72.0) }, config);
|
||||
CHECK(has_arc_after_first_layer(without_overhang_speed));
|
||||
|
||||
const std::string uniform_overhang_gcode = slice({ make_sphere(50.0, 2.0 * PI / 72.0) }, config);
|
||||
CHECK(has_arc_with_overhang_fan(uniform_overhang_gcode));
|
||||
}
|
||||
|
||||
TEST_CASE("Overhang fan transitions do not depend on overhang speed", "[Cooling][Regression]")
|
||||
{
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
|
||||
Reference in New Issue
Block a user