✨ Serialize render-wasm builds per checkout (#11903)

Protect shared setup, compilation, artifact copy, and target cleanup with
one flock lock per checkout. Route watch builds and frontend cleanup
through the protected scripts.

Document the lock contract and normalize the frontend and exporter
build:wasm commands.

Closes #11901

AI-assisted-by: Space Bunny Free
This commit is contained in:
Andrey Antukh 2026-09-25 09:42:27 +02:00 committed by GitHub
parent e4a8aa5c62
commit 5ef70c7284
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 166 additions and 35 deletions

View File

@ -20,11 +20,18 @@
`./build` sources `_build_env`, which sets the Emscripten paths and `EMCC_CFLAGS`. The WASM heap starts at 256 MB and uses geometric growth.
- Linux builds require `flock` (util-linux).
- `build`, target cleanup, and each watch rebuild share `render-wasm/.render-wasm-build.lock` per checkout. The dispatcher for both targets does not hold the lock in its parent process.
- The lock covers setup, Cargo build, and artifact copy. The watch process releases it while waiting for changes. The operating system releases it on success, error, or signal; the file remains.
- The lock only coordinates repository scripts. Manual Cargo commands do not take it.
- Set `RENDER_WASM_LOCK_FILE` to the same path only when separate checkouts intentionally share a `CARGO_TARGET_DIR`.
## Commands
From `render-wasm/`:
- Build/copy frontend artifacts: `./build`.
- Watch rebuild: `./watch`.
- Build/copy frontend artifacts: `./build [frontend|export]`; no target builds frontend, then export.
- Clean one target: `./clean [frontend|export]`; never run root `cargo clean` on the shared `target/`.
- Watch rebuild: `./watch [frontend|export]`; its initial and change-triggered builds use `./build`.
- Rust tests: `./test` or `cargo test <name>`.
- Cross-cutting testing principles and anti-patterns: `mem:testing`.
- Lint: `./lint`.

View File

@ -30,7 +30,6 @@
},
"scripts": {
"clear:shadow-cache": "rm -rf .shadow-cljs && rm -rf target",
"build:wasm": "exit 0",
"watch:app": "pnpm run build:wasm && pnpm run clear:shadow-cache && clojure -M:dev:shadow-cljs watch main",
"watch": "pnpm run watch:app",
"build:app": "clojure -M:dev:shadow-cljs release main",

View File

@ -44,7 +44,7 @@
"watch:app:libs": "node ./scripts/build-libs.js --watch",
"watch:app:main": "clojure -M:dev:shadow-cljs watch main worker storybook",
"clear:shadow-cache": "rm -rf .shadow-cljs",
"clear:wasm": "cargo clean --manifest-path ../render-wasm/Cargo.toml",
"clear:wasm": "../render-wasm/clean frontend",
"watch": "exit 0",
"watch:app": "pnpm run clear:shadow-cache && pnpm run clear:wasm && pnpm run build:wasm && concurrently --kill-others-on-fail \"pnpm run watch:app:assets\" \"pnpm run watch:app:main\" \"pnpm run watch:app:libs\"",
"watch:storybook": "pnpm run build:storybook:assets && concurrently --kill-others-on-fail \"storybook dev -p 3451 -h 0.0.0.0 --no-open\" \"node ./scripts/watch-storybook.js\"",

View File

@ -1,5 +1,6 @@
target/
debug/
.render-wasm-build.lock
**/*.rs.bk

View File

@ -27,7 +27,7 @@ cd penpot/render-wasm
You can also use `./watch` to run the build on every change.
The build script will compile the project and copy the `.js` and `.wasm` files to their correct location within the frontend app.
The build script compiles the project and copies the `.js` and `.wasm` files to the app that uses each target.
### Render targets
@ -52,6 +52,34 @@ Each target keeps its own `CARGO_TARGET_DIR` (`target/<target>`), so switching
between them does not invalidate the other's cache. Set `BUILD_MODE=release`
(or `NODE_ENV=production`) for an optimized build; the default is `debug`.
### Serialize builds in one checkout
All targets in one checkout share one `flock` lock stored at
`render-wasm/.render-wasm-build.lock`. The lock covers dependency setup, the
Cargo build, artifact copy, and target cleanup. If another build or cleanup
already holds the lock, the new process prints a waiting message and starts
only after the first process exits.
The watch command takes the lock for each build, then releases it while it
waits for source changes. The lock file stays in the checkout after a build,
but the operating system releases its lock when the process exits, including
after an error or signal.
The lock applies to one checkout and only to commands that use these scripts.
A manual Cargo build can still write to the same target without taking the
lock. Set `RENDER_WASM_LOCK_FILE` to the same path in each process when you
make different checkouts share a `CARGO_TARGET_DIR`. The supported Linux build
environment must provide `flock` from util-linux; `flock --version` checks this
dependency.
Use the target cleanup commands instead of running `cargo clean` on the shared
`target/` directory:
```sh
./clean frontend # remove target/frontend only
./clean export # remove target/export only
```
Each target writes its own generated `shared.js` (the enum discriminants the
CLJS side compiles against) next to the code that imports it — respectively
`frontend/src/app/render_wasm/api/shared.js` and

53
render-wasm/_build_lock Normal file
View File

@ -0,0 +1,53 @@
#!/usr/bin/env bash
_render_wasm_lock_source=${BASH_SOURCE[0]}
_render_wasm_lock_dir=${_render_wasm_lock_source%/*}
if [[ "$_render_wasm_lock_dir" == "$_render_wasm_lock_source" ]]; then
_render_wasm_lock_dir="."
fi
_RENDER_WASM_LOCK_DIR=$(cd -- "$_render_wasm_lock_dir" && pwd)
_RENDER_WASM_BUILD_LOCK_HELD=0
release_build_lock() {
if [[ "$_RENDER_WASM_BUILD_LOCK_HELD" == "1" ]]; then
_RENDER_WASM_BUILD_LOCK_HELD=0
flock --unlock 9 2>/dev/null || true
fi
exec 9>&-
}
acquire_build_lock() {
if ! command -v flock > /dev/null 2>&1; then
echo "ERROR: render-wasm builds require flock from util-linux on Linux" >&2
return 1
fi
if [[ "$_RENDER_WASM_BUILD_LOCK_HELD" == "1" ]]; then
return 0
fi
local lock_file="${RENDER_WASM_LOCK_FILE:-$_RENDER_WASM_LOCK_DIR/.render-wasm-build.lock}"
if ! exec 9>> "$lock_file"; then
echo "ERROR: cannot open render-wasm build lock: $lock_file" >&2
return 1
fi
trap 'release_build_lock' EXIT
trap 'release_build_lock; exit 129' HUP
trap 'release_build_lock; exit 130' INT
trap 'release_build_lock; exit 131' QUIT
trap 'release_build_lock; exit 143' TERM
if ! flock --exclusive --nonblock 9; then
echo "Waiting for another render-wasm build to finish: $lock_file" >&2
if ! flock --exclusive 9; then
echo "ERROR: cannot acquire render-wasm build lock: $lock_file" >&2
release_build_lock
return 1
fi
fi
_RENDER_WASM_BUILD_LOCK_HELD=1
}

View File

@ -5,32 +5,40 @@
# With no target, builds both. Set BUILD_MODE=release (or NODE_ENV=production)
# for an optimized build. See `_build_env` for what each target changes.
_SCRIPT_DIR=$(dirname $0);
_SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
_RENDER_TARGET=${1:-}
# Each target needs its own `_build_env`, so re-enter per target.
case "${1:-}" in
frontend|export)
case "$_RENDER_TARGET" in
frontend|export) ;;
"")
# Each target needs its own `_build_env`, so re-enter per target.
for _target in frontend export; do
"$_SCRIPT_DIR/build" "$_target" "$@" || exit $?
done
exit 0
;;
*)
for _target in frontend export; do
"$_SCRIPT_DIR/build" "$_target" "$@" || exit $?;
done
exit 0;
echo "ERROR: unknown render target '$_RENDER_TARGET' (expected 'frontend' or 'export')" >&2
exit 1
;;
esac
set -e
EMSDK_QUIET=1 . /opt/emsdk/emsdk_env.sh
pushd $_SCRIPT_DIR;
pushd "$_SCRIPT_DIR" > /dev/null
. ./_build_env
export RENDER_TARGET="$_RENDER_TARGET"
. ./_build_env "$_RENDER_TARGET"
. ./_build_lock
set -ex;
acquire_build_lock
setup;
build;
copy_target_artifacts;
set -x
exit $?;
setup
build
copy_target_artifacts
popd
popd > /dev/null

31
render-wasm/clean Executable file
View File

@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Usage: ./clean [frontend|export]
set -e
_SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
_RENDER_TARGET=${1:-frontend}
case "$_RENDER_TARGET" in
frontend|export) ;;
*)
echo "ERROR: unknown render target '$_RENDER_TARGET' (expected 'frontend' or 'export')" >&2
exit 1
;;
esac
pushd "$_SCRIPT_DIR" > /dev/null
export RENDER_TARGET="$_RENDER_TARGET"
unset CARGO_TARGET_DIR
. ./_build_env "$_RENDER_TARGET"
. ./_build_lock
acquire_build_lock
cargo clean --target-dir "$CARGO_TARGET_DIR"
RESULT=$?
popd > /dev/null
exit $RESULT

View File

@ -4,14 +4,14 @@ set -x
export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.93.1/skia-binaries-319323662b1685a112f5-x86_64-unknown-linux-gnu-gl-svg-textlayout-binary-cache-webp.tar.gz"}
export CARGO_BUILD_TARGET=${CARGO_BUILD_TARGET:-"x86_64-unknown-linux-gnu"};
_SCRIPT_DIR=$(dirname $0);
pushd $_SCRIPT_DIR;
_SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
pushd "$_SCRIPT_DIR" > /dev/null
. ./_build_env frontend
cargo test --bin render_wasm -- --show-output
# Exit with the same status code as cargo test
exit $?
RESULT=$?
popd > /dev/null
popd
exit $RESULT

View File

@ -2,22 +2,26 @@
# Usage: ./watch [frontend|export]
_SCRIPT_DIR=$(dirname $0);
pushd $_SCRIPT_DIR;
set -e
. ./_build_env
_SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
pushd "$_SCRIPT_DIR" > /dev/null
_RENDER_TARGET=${1:-${RENDER_TARGET:-frontend}}
export RENDER_TARGET="$_RENDER_TARGET"
. ./_build_env "$_RENDER_TARGET" "${@:2}"
# The initial build uses the protected build entry point. Its lock is released
# before cargo watch starts waiting for source changes.
"$_SCRIPT_DIR/build" "$RENDER_TARGET" $CARGO_PARAMS
set -x
build;
copy_target_artifacts;
pushd $_SCRIPT_DIR;
cargo watch \
--why \
-i "_tmp*" \
-x "build $CARGO_PARAMS" \
-s "./build $RENDER_TARGET" \
-s "\"$_SCRIPT_DIR/build\" \"$RENDER_TARGET\" $CARGO_PARAMS" \
-s "echo 'DONE\n'";
popd
popd > /dev/null