diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17a793e5..3d52484f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,8 +16,13 @@ concurrency: env: CARGO_BUILD_JOBS: '6' - BINARYEN_CORES: '6' + BINARYEN_CORES: '2' CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: '0' + RUSTC_WRAPPER: sccache + SCCACHE_DIR: ${{ github.workspace }}/.sccache + SCCACHE_CACHE_SIZE: 512M + SCCACHE_IGNORE_SERVER_IO_ERROR: '1' jobs: security: @@ -46,7 +51,6 @@ jobs: quality: name: Rust quality - needs: security runs-on: ubuntu-24.04 timeout-minutes: 45 steps: @@ -60,10 +64,16 @@ jobs: toolchain: '1.98.0' components: rustfmt, clippy targets: wasm32-unknown-unknown + - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + with: + version: v0.16.0 - uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2 with: key: quality-static cache-on-failure: true + cache-targets: false + cache-directories: ${{ env.SCCACHE_DIR }} + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Validate registry manifests run: python3 scripts/release.py check - name: Run source, formatting, Clippy, Wasm, and public API checks @@ -71,7 +81,6 @@ jobs: semver: name: Public API - needs: security runs-on: ubuntu-24.04 timeout-minutes: 30 steps: @@ -81,18 +90,29 @@ jobs: - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: toolchain: '1.98.0' + - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + with: + version: v0.16.0 + - name: Install Linux build dependencies + run: ./scripts/install-linux-ci.sh - uses: taiki-e/install-action@3f74d7c16a4242f1c95561e98edc25d36adb4375 # v2 with: tool: cargo-semver-checks@0.49.0 - uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2 with: key: semver + cache-targets: false + cache-directories: ${{ env.SCCACHE_DIR }} + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Compare publishable APIs with crates.io - run: cargo semver-checks check-release --workspace --release-type minor + # A registry baseline does not exist for argui-testing until its first release. + run: >- + cargo semver-checks check-release --workspace + --exclude argui-testing + --release-type major coverage: name: Rust coverage - needs: security runs-on: ubuntu-24.04 timeout-minutes: 90 env: @@ -115,6 +135,9 @@ jobs: toolchain: '1.98.0' components: rustfmt, clippy targets: wasm32-unknown-unknown + - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + with: + version: v0.16.0 - name: Install pinned coverage toolchain run: rustup toolchain install "$ARGUI_COVERAGE_TOOLCHAIN" --profile minimal --component llvm-tools-preview - uses: taiki-e/install-action@3f74d7c16a4242f1c95561e98edc25d36adb4375 # v2 @@ -124,6 +147,9 @@ jobs: with: key: quality cache-on-failure: true + cache-targets: false + cache-directories: ${{ env.SCCACHE_DIR }} + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Validate registry manifests run: python3 scripts/release.py check - name: Run the 85% coverage gate on a private display @@ -141,7 +167,6 @@ jobs: desktop: name: Desktop compile (${{ matrix.name }}) - needs: security strategy: fail-fast: false matrix: @@ -161,15 +186,20 @@ jobs: - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: toolchain: '1.98.0' + - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + with: + version: v0.16.0 - uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2 with: key: desktop-${{ matrix.cache }} + cache-targets: false + cache-directories: ${{ env.SCCACHE_DIR }} + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Compile the complete workspace run: cargo check --workspace --all-targets --all-features --locked mobile: name: Mobile cross-check - needs: security runs-on: ubuntu-24.04 timeout-minutes: 45 steps: @@ -180,9 +210,15 @@ jobs: with: toolchain: '1.98.0' targets: aarch64-linux-android,aarch64-apple-ios + - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + with: + version: v0.16.0 - uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2 with: key: mobile-cross-check + cache-targets: false + cache-directories: ${{ env.SCCACHE_DIR }} + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Check every supported Android capability and gallery entry point run: | cargo check --locked --target aarch64-linux-android -p argui -p argui-android \ @@ -196,7 +232,7 @@ jobs: android-package: name: Android APK and AAB - needs: [security, mobile] + needs: mobile runs-on: ubuntu-24.04 timeout-minutes: 90 steps: @@ -207,6 +243,9 @@ jobs: with: toolchain: '1.98.0' targets: aarch64-linux-android + - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + with: + version: v0.16.0 - uses: taiki-e/install-action@3f74d7c16a4242f1c95561e98edc25d36adb4375 # v2 with: tool: cargo-ndk@4.1.2 @@ -217,8 +256,10 @@ jobs: - uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2 with: key: android-package - cache-workspace-crates: true cache-on-failure: true + cache-targets: false + cache-directories: ${{ env.SCCACHE_DIR }} + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Configure the pinned Gradle wrapper and cache uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6 with: @@ -246,7 +287,6 @@ jobs: ios-package: name: iOS XCFramework and Simulator app - needs: security runs-on: macos-15 timeout-minutes: 90 steps: @@ -257,11 +297,16 @@ jobs: with: toolchain: '1.98.0' targets: aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios + - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + with: + version: v0.16.0 - uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2 with: key: ios-package - cache-workspace-crates: true cache-on-failure: true + cache-targets: false + cache-directories: ${{ env.SCCACHE_DIR }} + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Build the XCFramework and unsigned Simulator app run: | ./scripts/ios-widget-gallery.sh all @@ -286,7 +331,6 @@ jobs: packages: name: Crates.io archives - needs: security runs-on: ubuntu-24.04 timeout-minutes: 60 steps: @@ -298,21 +342,25 @@ jobs: - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: toolchain: '1.98.0' + - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + with: + version: v0.16.0 - uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2 with: - shared-key: linux-distribution - add-job-id-key: false - cache-workspace-crates: true + key: packages cache-on-failure: true + cache-targets: false + cache-directories: ${{ env.SCCACHE_DIR }} + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Package and compile every publishable crate archive run: python3 scripts/release.py package website: name: Website - needs: security runs-on: ubuntu-24.04 timeout-minutes: 45 env: + ARGUI_WASM_BUILD_CONCURRENCY: '2' NUXT_APP_BASE_URL: /argui/ NUXT_PUBLIC_SITE_URL: https://extrabinoss.github.io/argui steps: @@ -331,12 +379,18 @@ jobs: with: toolchain: '1.98.0' targets: wasm32-unknown-unknown + - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + with: + version: v0.16.0 - uses: taiki-e/install-action@3f74d7c16a4242f1c95561e98edc25d36adb4375 # v2 with: tool: wasm-pack@0.15.0 - uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2 with: key: website-wasm + cache-targets: false + cache-directories: ${{ env.SCCACHE_DIR }} + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Install website dependencies run: pnpm --dir website install --frozen-lockfile - name: Check the website @@ -398,13 +452,17 @@ jobs: - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: toolchain: '1.98.0' + - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + with: + version: v0.16.0 - name: Install native build dependencies run: ./scripts/install-linux-ci.sh - uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2 with: - shared-key: linux-distribution - add-job-id-key: false - cache-workspace-crates: true + key: release + cache-targets: false + cache-directories: ${{ env.SCCACHE_DIR }} + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Check the version and publish missing crates env: PUBLISH_BASE: ${{ github.event.before }} diff --git a/crates/argui-ios/swift/activity-kit/app/ArguiActivityBridge.swift b/crates/argui-ios/swift/activity-kit/app/ArguiActivityBridge.swift index eeb051cd..86c99a28 100644 --- a/crates/argui-ios/swift/activity-kit/app/ArguiActivityBridge.swift +++ b/crates/argui-ios/swift/activity-kit/app/ArguiActivityBridge.swift @@ -99,7 +99,11 @@ public func arguiIOSActivityUpdate( guard #available(iOS 16.1, *) else { return 0 } - ArguiActivityBridge.update(identifier: identifier, percent: percent, message: messageText) + return ArguiActivityBridge.update( + identifier: identifier, + percent: percent, + message: messageText + ) } } @@ -109,7 +113,7 @@ public func arguiIOSActivityFinish(_ identifier: UInt64) -> Int32 { guard #available(iOS 16.1, *) else { return 0 } - ArguiActivityBridge.finish(identifier: identifier) + return ArguiActivityBridge.finish(identifier: identifier) } } diff --git a/crates/argui-runtime/tests/launch.rs b/crates/argui-runtime/tests/launch.rs index 25d44721..b0a7414c 100644 --- a/crates/argui-runtime/tests/launch.rs +++ b/crates/argui-runtime/tests/launch.rs @@ -214,7 +214,7 @@ mod native { } self.issued = phase; eprintln!("native lifecycle command {phase}"); - if phase < 8 { + if phase < 6 { self.timer = Some(self.data.update(|_, cx| { cx.spawn( async { @@ -230,7 +230,7 @@ mod native { })); } #[cfg(all(feature = "webview", target_os = "linux"))] - if phase == 6 { + if phase == 5 { crate::gtk_input::send(); } let auxiliary = WindowKey::new("auxiliary"); @@ -266,6 +266,9 @@ mod native { window: WindowKey::main(), maximized: phase == 3, }), + 6 => update + .command(AppCommand::FocusWindow(WindowKey::new("auxiliary"))) + .command(AppCommand::Quit), _ => update, } } @@ -437,7 +440,7 @@ mod native { ) .unwrap(); assert!(errors.borrow().is_empty(), "{:?}", errors.borrow()); - assert_eq!(data.read(|data| data.phase), 8); + assert_eq!(data.read(|data| data.phase), 6); #[cfg(all(feature = "webview", target_os = "linux"))] animation.assert_smooth(); assert!( diff --git a/docs/contributing/releases.md b/docs/contributing/releases.md index 04269483..9260abbc 100644 --- a/docs/contributing/releases.md +++ b/docs/contributing/releases.md @@ -2,10 +2,18 @@ The [CI workflow](../../.github/workflows/ci.yml) runs independent jobs for security, static Rust quality, public API/SemVer compatibility, Rust coverage, -desktop targets, mobile targets, package archives, and the website. Keeping static checks and instrumented -coverage separate gives each expensive build its own timeout without weakening -the local combined gate. GitHub Pages depends only on the website job. Crate -publication waits for every required job. +desktop targets, mobile targets, package archives, and the website. Keeping +static checks and instrumented coverage separate gives each expensive build its +own timeout without weakening the local combined gate. Independent jobs start +immediately; GitHub Pages waits for both security and the website, while crate +publication waits for every required job. Rust jobs use `sccache` for compiler +outputs and cache Cargo's registry separately instead of uploading complete +`target/` directories. + +The public API job compares every previously published crate with its latest +crates.io release. A new crate without a registry baseline is explicitly +excluded until its first release; currently this applies only to +`argui-testing`. ## Prepare a version @@ -22,7 +30,9 @@ python3 scripts/release.py package `check` rejects mismatched or invalid manifests. `package` computes the dependency order from Cargo metadata, creates every archive without resolving older published workspace versions, and then compiles each extracted archive -against the previously staged 0.3 sources. +against the previously staged 0.3 sources. Packaging and verification use +temporary target directories, so this command never cleans or pollutes a target +directory shared with an editor or another worktree. Review the generated diff and run the final [quality gate](code-quality.md#final-gate). The final commit on the push must diff --git a/scripts/release.py b/scripts/release.py index 856f1c96..cbe4fcde 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -7,7 +7,6 @@ import os from pathlib import Path import re -import shutil import subprocess import sys import tarfile @@ -35,9 +34,15 @@ def version_key(value): tuple((0, int(part)) if part.isdigit() else (1, part) for part in identifiers)) -def run(*args, capture=False): +def run(*args, capture=False, env=None): + """Run a release command from the repository root. + + ``env`` can override the process environment for isolated Cargo + operations without changing the environment used by ordinary release + checks. + """ result = subprocess.run(args, cwd=ROOT, check=True, text=True, - stdout=subprocess.PIPE if capture else None) + stdout=subprocess.PIPE if capture else None, env=env) return result.stdout.strip() if capture else None @@ -103,34 +108,25 @@ def workspace(): def package_archives(): current, names = workspace() - metadata = json.loads(run('cargo', 'metadata', '--locked', '--no-deps', - '--format-version', '1', capture=True)) - package_directory = Path(metadata['target_directory']) / 'package' - if package_directory.exists(): - shutil.rmtree(package_directory) - clean = ['cargo', 'clean', '--locked', '--profile', 'dev'] - for name in names: - clean.extend(['--package', name]) - run(*clean) selection = [argument for name in names for argument in ('--package', name)] - # Package the complete publishable graph in one Cargo transaction so - # unpublished workspace versions resolve to one another instead of crates.io. - run('cargo', 'package', '--locked', '--all-features', '--allow-dirty', - '--no-verify', *selection) - verify_staged_archives(current, names) + # Keep package artifacts out of the checkout's shared target. In + # particular, never clean that target: it may be shared by an IDE, other + # worktrees, or sccache-backed builds running at the same time. + with tempfile.TemporaryDirectory(prefix='argui-package-target-') as directory: + target = Path(directory) + environment = os.environ.copy() + environment['CARGO_TARGET_DIR'] = str(target) + # Package the complete publishable graph in one Cargo transaction so + # unpublished workspace versions resolve to one another instead of + # crates.io. + run('cargo', 'package', '--locked', '--all-features', '--allow-dirty', + '--no-verify', *selection, env=environment) + verify_staged_archives(current, names, target) print(f'Verified {len(names)} crates.io archives at version {current}') -def shared_target_directory(): - """Return Cargo's configured target directory for this checkout.""" - metadata = json.loads(run('cargo', 'metadata', '--locked', '--no-deps', - '--format-version', '1', capture=True)) - return Path(metadata['target_directory']) - - -def verify_staged_archives(current, names): +def verify_staged_archives(current, names, package_target): """Compile packaged sources together against only the staged archives.""" - target = shared_target_directory() with tempfile.TemporaryDirectory(prefix='argui-package-') as directory: staged = Path(directory) config = staged / '.cargo' / 'config.toml' @@ -138,7 +134,7 @@ def verify_staged_archives(current, names): patches = [] members = [] for name in names: - archive = target / 'package' / f'{name}-{current}.crate' + archive = package_target / 'package' / f'{name}-{current}.crate' if not archive.is_file(): raise ValueError(f'{name}: missing staged archive {archive}') with tarfile.open(archive, mode='r:gz') as package: @@ -153,9 +149,19 @@ def verify_staged_archives(current, names): '[workspace]\nresolver = "2"\nmembers = [' + ', '.join(members) + ']\n' ) environment = os.environ.copy() - environment['CARGO_TARGET_DIR'] = str(target) + # Keep verification's compiled output inside the temporary staging + # directory as well. The fetch/check order is intentional: fetch may + # populate the local registry, while check must prove the staged + # archives build without network access. + environment['CARGO_TARGET_DIR'] = str(staged / 'target') + subprocess.run( + ['cargo', 'fetch'], + cwd=staged, + env=environment, + check=True, + ) subprocess.run( - ['cargo', 'check', '--offline', '--workspace', '--all-features'], + ['cargo', 'check', '--offline', '--locked', '--workspace', '--all-features'], cwd=staged, env=environment, check=True, diff --git a/tests/scripts/test_release.py b/tests/scripts/test_release.py index e0cb3d75..2c8818d1 100644 --- a/tests/scripts/test_release.py +++ b/tests/scripts/test_release.py @@ -206,26 +206,76 @@ def test_publication_order_rejects_cycles_and_facade_dependents(self): def test_package_verifies_every_archive_in_publication_order(self): names = ['argui-core', 'argui-render', 'argui'] + targets = [] + + def verify(current, actual_names, target): + self.assertEqual((current, actual_names), ('0.1.0', names)) + self.assertTrue(target.is_dir()) + targets.append(target) + with tempfile.TemporaryDirectory() as directory: - package_directory = Path(directory) / 'package' - package_directory.mkdir() - (package_directory / 'stale-registry').write_text('old archive') - metadata = json.dumps({'target_directory': directory}) + shared_package_directory = Path(directory) / 'package' + shared_package_directory.mkdir() + (shared_package_directory / 'stale-registry').write_text('old archive') with patch.object(release, 'workspace', return_value=('0.1.0', names)), \ - patch.object(release, 'run', side_effect=[metadata, None, None]) as run, \ - patch.object(release, 'verify_staged_archives') as verify: + patch.object(release, 'run') as run, \ + patch.object(release, 'verify_staged_archives', side_effect=verify): release.package_archives() - self.assertFalse(package_directory.exists()) - self.assertEqual(run.call_args_list[1].args, ( - 'cargo', 'clean', '--locked', '--profile', 'dev', - '--package', 'argui-core', '--package', 'argui-render', '--package', 'argui', - )) - self.assertEqual(run.call_args_list[2].args, ( + self.assertTrue((shared_package_directory / 'stale-registry').exists()) + self.assertEqual(run.call_count, 1) + self.assertEqual(run.call_args.args, ( 'cargo', 'package', '--locked', '--all-features', '--allow-dirty', '--no-verify', '--package', 'argui-core', '--package', 'argui-render', '--package', 'argui', )) - verify.assert_called_once_with('0.1.0', names) + target = targets[0] + self.assertFalse(target.exists()) + self.assertEqual(Path(run.call_args.kwargs['env']['CARGO_TARGET_DIR']), target) + + def test_archive_verification_fetches_before_the_offline_locked_check(self): + names = ['argui-core', 'argui'] + + class Archive: + def __init__(self, name): + self.name = name + + def __enter__(self): + return self + + def __exit__(self, *_): + return False + + def extractall(self, destination, filter): + if filter != 'data': + raise AssertionError(f'unexpected archive filter: {filter}') + source = destination / self.name.removesuffix('.crate') + source.mkdir() + (source / 'Cargo.toml').write_text('[package]\nname = "staged"\n') + + with tempfile.TemporaryDirectory() as directory: + target = Path(directory) + package_directory = target / 'package' + package_directory.mkdir() + for name in names: + (package_directory / f'{name}-0.3.0.crate').touch() + + def archive(path, mode): + self.assertEqual(mode, 'r:gz') + return Archive(Path(path).name) + + with patch.object(release.tarfile, 'open', side_effect=archive), \ + patch.object(release.subprocess, 'run') as run: + release.verify_staged_archives('0.3.0', names, target) + + self.assertEqual(run.call_count, 2) + self.assertEqual(run.call_args_list[0].args[0], ['cargo', 'fetch']) + self.assertEqual( + run.call_args_list[1].args[0], + ['cargo', 'check', '--offline', '--locked', '--workspace', '--all-features'], + ) + self.assertNotEqual( + Path(run.call_args_list[1].kwargs['env']['CARGO_TARGET_DIR']), target, + ) def test_unchanged_version_never_runs_a_publisher(self): with patch.object(release, 'run') as run: diff --git a/website/scripts/gallery.mjs b/website/scripts/gallery.mjs index 51b48719..72bdfe14 100644 --- a/website/scripts/gallery.mjs +++ b/website/scripts/gallery.mjs @@ -1,5 +1,5 @@ import { access, cp, mkdir, rm } from 'node:fs/promises' -import { spawnSync } from 'node:child_process' +import { spawn } from 'node:child_process' import { fileURLToPath } from 'node:url' import { resolve } from 'node:path' @@ -8,11 +8,137 @@ const destination = resolve(root, 'website/public/gallery') const aiDestination = resolve(root, 'website/public/examples/ai-harness') const gpuDestination = resolve(root, 'website/public/examples/gpu-canvas') const docsDestination = resolve(root, 'website/public/examples/docs') -const wasmEnvironment = { - ...process.env, - CARGO_BUILD_JOBS: '6', - BINARYEN_CORES: '6', +const wasmEnvironment = { ...process.env } +const defaultBuildConcurrency = 2 + +const wasmBuilds = [ + { + label: 'widget gallery', + args: [ + 'build', + 'crates/argui-widget-gallery', + '--target', + 'web', + '--release', + '--out-dir', + '../../web/widgets/pkg', + '--all-features', + ], + }, + { + label: 'AI harness example', + args: [ + 'build', + 'app_examples/fake-ai-harness', + '--target', + 'web', + '--release', + '--out-dir', + '../../web/examples/ai-harness/pkg', + ], + }, + { + label: 'GPU canvas example', + args: [ + 'build', + 'app_examples/gpu-canvas', + '--target', + 'web', + '--release', + '--out-dir', + '../../web/examples/gpu-canvas/pkg', + ], + }, + { + label: 'docs examples', + args: [ + 'build', + 'app_examples/docs-examples', + '--target', + 'web', + '--release', + '--out-dir', + '../../web/examples/docs/pkg', + ], + }, +] + +/** + * Reads a positive integer setting without allowing malformed values to alter + * the worker pool unexpectedly. + * + * @param {string | undefined} value The environment value to parse. + * @param {number} fallback The value to use when the setting is invalid. + * @returns {number} A positive integer suitable for a worker count. + */ +function positiveInteger(value, fallback) { + if (!value || !/^\d+$/.test(value)) return fallback + const parsed = Number(value) + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback +} + +/** + * Runs one wasm-pack build while streaming its output directly to the caller. + * + * @param {{ label: string, args: string[] }} build The build command to run. + * @returns {Promise} Resolves when the build succeeds. + * @throws {Error} If wasm-pack cannot start or exits unsuccessfully. + */ +function runWasmBuild(build) { + return new Promise((resolveBuild, rejectBuild) => { + const child = spawn('wasm-pack', build.args, { + cwd: root, + env: wasmEnvironment, + stdio: 'inherit', + }) + + child.once('error', (error) => { + rejectBuild(new Error(`${build.label} could not start: ${error.message}`, { cause: error })) + }) + child.once('close', (code, signal) => { + if (code === 0) { + resolveBuild() + return + } + const reason = signal ? `signal ${signal}` : `exit code ${code ?? 'unknown'}` + const error = new Error(`${build.label} failed with ${reason}`) + if (code !== null) error.exitCode = code + rejectBuild(error) + }) + }) } + +/** + * Runs wasm builds through a bounded worker pool so independent bundles can + * overlap without spawning one process per bundle at once. + * + * @param {{ label: string, args: string[] }[]} builds The builds to execute. + * @param {number} concurrency The maximum number of active wasm-pack processes. + * @returns {Promise} Resolves when every build succeeds. + * @throws {Error} The first build error after active workers have drained. + */ +async function runWasmBuilds(builds, concurrency) { + let nextBuild = 0 + let firstError + + async function worker() { + while (firstError === undefined) { + const build = builds[nextBuild] + nextBuild += 1 + if (!build) return + try { + await runWasmBuild(build) + } catch (error) { + firstError = error + } + } + } + + const workerCount = Math.min(concurrency, builds.length) + await Promise.all(Array.from({ length: workerCount }, () => worker())) + if (firstError) throw firstError +} + if (process.argv.includes('--check')) { try { await access(resolve(destination, 'pkg/argui_widget_gallery_bg.wasm')) @@ -27,67 +153,17 @@ if (process.argv.includes('--check')) { } } else { if (!process.argv.includes('--copy')) { - const build = spawnSync( - 'wasm-pack', - [ - 'build', - 'crates/argui-widget-gallery', - '--target', - 'web', - '--release', - '--out-dir', - '../../web/widgets/pkg', - '--all-features', - ], - { - cwd: root, - stdio: 'inherit', - env: wasmEnvironment, - }, - ) - if (build.status !== 0) process.exit(build.status ?? 1) - const exampleBuild = spawnSync( - 'wasm-pack', - [ - 'build', - 'app_examples/fake-ai-harness', - '--target', - 'web', - '--release', - '--out-dir', - '../../web/examples/ai-harness/pkg', - ], - { cwd: root, stdio: 'inherit', env: wasmEnvironment }, - ) - if (exampleBuild.status !== 0) process.exit(exampleBuild.status ?? 1) - const gpuBuild = spawnSync( - 'wasm-pack', - [ - 'build', - 'app_examples/gpu-canvas', - '--target', - 'web', - '--release', - '--out-dir', - '../../web/examples/gpu-canvas/pkg', - ], - { cwd: root, stdio: 'inherit', env: wasmEnvironment }, - ) - if (gpuBuild.status !== 0) process.exit(gpuBuild.status ?? 1) - const docsBuild = spawnSync( - 'wasm-pack', - [ - 'build', - 'app_examples/docs-examples', - '--target', - 'web', - '--release', - '--out-dir', - '../../web/examples/docs/pkg', - ], - { cwd: root, stdio: 'inherit', env: wasmEnvironment }, + const concurrency = positiveInteger( + process.env.ARGUI_WASM_BUILD_CONCURRENCY, + defaultBuildConcurrency, ) - if (docsBuild.status !== 0) process.exit(docsBuild.status ?? 1) + try { + await runWasmBuilds(wasmBuilds, concurrency) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(`WASM gallery build failed: ${message}`) + process.exit(error?.exitCode ?? 1) + } } await access(resolve(root, 'web/widgets/pkg/argui_widget_gallery_bg.wasm')) await access(resolve(root, 'web/examples/ai-harness/pkg/argui_example_ai_harness_bg.wasm'))