diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
new file mode 100644
index 00000000..108331fe
--- /dev/null
+++ b/.github/workflows/nightly.yml
@@ -0,0 +1,466 @@
+# SPDX-License-Identifier: MPL-2.0
+name: Wave Nightly
+'on':
+ workflow_run:
+ workflows:
+ - Wave CI
+ types:
+ - completed
+ branches:
+ - master
+ workflow_dispatch:
+ inputs:
+ ci_run_id:
+ description: Successful canonical master Wave CI run ID to retry
+ required: true
+ type: string
+permissions:
+ contents: read
+ actions: read
+env:
+ CARGO_TERM_COLOR: always
+ CARGO_BUILD_JOBS: '2'
+ NO_COLOR: '1'
+ PYTHONUNBUFFERED: '1'
+ PYTHONUTF8: '1'
+ PYTHONIOENCODING: utf-8
+jobs:
+ gate:
+ if: >-
+ ${{ github.repository == 'wavefnd/Wave' &&
+ ((github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/master') ||
+ (github.event.workflow_run.conclusion == 'success' &&
+ github.event.workflow_run.event == 'push' &&
+ github.event.workflow_run.head_repository.full_name == 'wavefnd/Wave' &&
+ github.event.workflow_run.head_branch == 'master')) }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ outputs:
+ source_sha: ${{ steps.gate.outputs.source_sha }}
+ compiler_version: ${{ steps.gate.outputs.compiler_version }}
+ ci_run_id: ${{ steps.gate.outputs.ci_run_id }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: master
+ persist-credentials: false
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+ - name: Validate canonical CI source and installer deployment
+ id: gate
+ env:
+ GH_TOKEN: ${{ github.token }}
+ WAVE_NIGHTLY_CI_RUN: ${{ github.event.workflow_run.id || inputs.ci_run_id }}
+ run: python -m tools.ci.release --channel nightly --stage gate
+ package-linux:
+ name: Package Linux ${{ matrix.arch == 'x86_64' && 'amd64' || matrix.arch }}
+ needs:
+ - gate
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 60
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - arch: x86_64
+ runner: ubuntu-24.04
+ target: x86_64-unknown-linux-gnu
+ archive_target: x86_64-linux-gnu
+ - arch: arm64
+ runner: ubuntu-24.04-arm
+ target: aarch64-unknown-linux-gnu
+ archive_target: aarch64-linux-gnu
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ needs.gate.outputs.source_sha }}
+ persist-credentials: false
+ - name: Setup Rust
+ uses: dtolnay/rust-toolchain@1.89.0
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+ - name: Build and validate Nightly package
+ id: procedure
+ env:
+ WAVE_CI_MATRIX: ${{ toJSON(matrix) }}
+ GH_TOKEN: ${{ github.token }}
+ run: python -m tools.ci.release --channel nightly --stage package --target "${{ matrix.target }}" --provision --report-json
+ ci-report.json
+ - uses: actions/upload-artifact@v4
+ with:
+ name: nightly-linux-${{ matrix.arch }}
+ path: 'wave-v${{ needs.gate.outputs.compiler_version }}-${{ matrix.archive_target }}.tar.gz
+
+ wave-v${{ needs.gate.outputs.compiler_version }}-${{ matrix.archive_target }}.tar.gz.sha256
+
+ wave-v${{ needs.gate.outputs.compiler_version }}-${{ matrix.archive_target }}.tar.gz.metadata.json
+
+ '
+ if-no-files-found: error
+ retention-days: 7
+ - name: Upload procedure report and command logs
+ if: ${{ always() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: procedures-nightly-package-linux-${{ matrix.arch }}
+ path: 'ci-report.json
+
+ ${{ runner.temp }}/wave-ci-*/*.log
+
+ '
+ if-no-files-found: warn
+ retention-days: 7
+ env:
+ RELEASE_VERSION: ${{ needs.gate.outputs.compiler_version }}
+ WAVE_NIGHTLY_CI_RUN: ${{ needs.gate.outputs.ci_run_id }}
+ package-linux-loongarch64:
+ name: Package Linux loong64 (cross)
+ needs:
+ - gate
+ runs-on: ubuntu-24.04
+ timeout-minutes: 240
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ needs.gate.outputs.source_sha }}
+ persist-credentials: false
+ - name: Setup Rust
+ uses: dtolnay/rust-toolchain@1.89.0
+ - name: Enable LoongArch64 compiler execution
+ uses: docker/setup-qemu-action@v3
+ with:
+ platforms: loong64
+ - name: Cache cross-built LoongArch64 LLVM
+ uses: actions/cache@v4
+ with:
+ path: /tmp/wave-loongarch64-release
+ key: loongarch64-llvm-${{ runner.os }}-${{ hashFiles('tools/ci/toolchains.json') }}-${{ hashFiles('tools/package_linux_loongarch64.sh')
+ }}
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+ - name: Build and validate Nightly package
+ id: procedure
+ env:
+ WAVE_CI_MATRIX: ${{ toJSON(matrix) }}
+ GH_TOKEN: ${{ github.token }}
+ run: python -m tools.ci.release --channel nightly --stage package --target "linux-loong64" --provision --report-json
+ ci-report.json
+ - uses: actions/upload-artifact@v4
+ with:
+ name: nightly-linux-loongarch64
+ path: 'wave-v${{ needs.gate.outputs.compiler_version }}-loongarch64-linux-gnu.tar.gz
+
+ wave-v${{ needs.gate.outputs.compiler_version }}-loongarch64-linux-gnu.tar.gz.sha256
+
+ wave-v${{ needs.gate.outputs.compiler_version }}-loongarch64-linux-gnu.tar.gz.metadata.json
+
+ '
+ if-no-files-found: error
+ retention-days: 7
+ - name: Upload procedure report and command logs
+ if: ${{ always() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: procedures-nightly-package-linux-loongarch64-single
+ path: 'ci-report.json
+
+ ${{ runner.temp }}/wave-ci-*/*.log
+
+ '
+ if-no-files-found: warn
+ retention-days: 7
+ env:
+ RELEASE_VERSION: ${{ needs.gate.outputs.compiler_version }}
+ WAVE_NIGHTLY_CI_RUN: ${{ needs.gate.outputs.ci_run_id }}
+ package-linux-riscv64:
+ name: Package Linux riscv64 (cross)
+ needs:
+ - gate
+ runs-on: ubuntu-24.04
+ timeout-minutes: 360
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ needs.gate.outputs.source_sha }}
+ persist-credentials: false
+ - name: Enable RISC-V container execution
+ uses: docker/setup-qemu-action@v3
+ with:
+ platforms: riscv64
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+ - name: Build and validate Nightly package
+ id: procedure
+ env:
+ WAVE_CI_MATRIX: ${{ toJSON(matrix) }}
+ GH_TOKEN: ${{ github.token }}
+ run: python -m tools.ci.release --channel nightly --stage package --target "linux-riscv64" --provision --report-json
+ ci-report.json
+ - uses: actions/upload-artifact@v4
+ with:
+ name: nightly-linux-riscv64
+ path: 'wave-v${{ needs.gate.outputs.compiler_version }}-riscv64-linux-gnu.tar.gz
+
+ wave-v${{ needs.gate.outputs.compiler_version }}-riscv64-linux-gnu.tar.gz.sha256
+
+ wave-v${{ needs.gate.outputs.compiler_version }}-riscv64-linux-gnu.tar.gz.metadata.json
+
+ '
+ if-no-files-found: error
+ retention-days: 7
+ - name: Upload procedure report and command logs
+ if: ${{ always() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: procedures-nightly-package-linux-riscv64-single
+ path: 'ci-report.json
+
+ ${{ runner.temp }}/wave-ci-*/*.log
+
+ '
+ if-no-files-found: warn
+ retention-days: 7
+ env:
+ RELEASE_VERSION: ${{ needs.gate.outputs.compiler_version }}
+ WAVE_NIGHTLY_CI_RUN: ${{ needs.gate.outputs.ci_run_id }}
+ package-macos:
+ name: Package macOS ${{ matrix.arch == 'x86_64' && 'amd64' || matrix.arch }}
+ needs:
+ - gate
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 60
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - arch: x86_64
+ runner: macos-15-intel
+ target: x86_64-apple-darwin
+ - arch: arm64
+ runner: macos-15
+ target: aarch64-apple-darwin
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ needs.gate.outputs.source_sha }}
+ persist-credentials: false
+ - name: Setup Rust
+ uses: dtolnay/rust-toolchain@1.89.0
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+ - name: Build and validate Nightly package
+ id: procedure
+ env:
+ WAVE_CI_MATRIX: ${{ toJSON(matrix) }}
+ GH_TOKEN: ${{ github.token }}
+ run: python -m tools.ci.release --channel nightly --stage package --target "${{ matrix.target }}" --provision --report-json
+ ci-report.json
+ - uses: actions/upload-artifact@v4
+ with:
+ name: nightly-macos-${{ matrix.arch }}
+ path: 'wave-v${{ needs.gate.outputs.compiler_version }}-${{ matrix.target }}.tar.gz
+
+ wave-v${{ needs.gate.outputs.compiler_version }}-${{ matrix.target }}.tar.gz.sha256
+
+ wave-v${{ needs.gate.outputs.compiler_version }}-${{ matrix.target }}.tar.gz.metadata.json
+
+ '
+ if-no-files-found: error
+ retention-days: 7
+ - name: Upload procedure report and command logs
+ if: ${{ always() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: procedures-nightly-package-macos-${{ matrix.arch }}
+ path: 'ci-report.json
+
+ ${{ runner.temp }}/wave-ci-*/*.log
+
+ '
+ if-no-files-found: warn
+ retention-days: 7
+ env:
+ RELEASE_VERSION: ${{ needs.gate.outputs.compiler_version }}
+ WAVE_NIGHTLY_CI_RUN: ${{ needs.gate.outputs.ci_run_id }}
+ package-windows:
+ name: Package Windows amd64 (MSVC)
+ needs:
+ - gate
+ runs-on: windows-2025
+ timeout-minutes: 90
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ needs.gate.outputs.source_sha }}
+ persist-credentials: false
+ - uses: dtolnay/rust-toolchain@1.89.0
+ with:
+ toolchain: 1.89.0-x86_64-pc-windows-msvc
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+ architecture: x64
+ - name: Build and validate Nightly package
+ id: procedure
+ env:
+ WAVE_CI_MATRIX: ${{ toJSON(matrix) }}
+ GH_TOKEN: ${{ github.token }}
+ run: python -m tools.ci.release --channel nightly --stage package --target "windows-amd64" --provision --report-json
+ ci-report.json
+ - name: Preserve native execution evidence
+ if: ${{ always() && steps.procedure.outputs.native_acceptance != 'skipped' }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: msvc-native-x64-evidence
+ path: ${{ runner.temp }}/wave-msvc-native-x64/
+ retention-days: 7
+ - name: Preserve case evidence
+ if: ${{ always() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: wave-cases-windows-amd64
+ path: wave-cases-windows-amd64.json
+ - name: Preserve package acceptance evidence
+ if: ${{ always() && steps.procedure.outputs.package_smoke != 'skipped' }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: msvc-package-x64-evidence
+ path: ${{ runner.temp }}/wave-package-x64/
+ retention-days: 7
+ - uses: actions/upload-artifact@v4
+ with:
+ name: nightly-windows-x64
+ path: 'wave-v${{ needs.gate.outputs.compiler_version }}-x86_64-pc-windows-msvc.zip
+
+ wave-v${{ needs.gate.outputs.compiler_version }}-x86_64-pc-windows-msvc.zip.sha256
+
+ wave-v${{ needs.gate.outputs.compiler_version }}-x86_64-pc-windows-msvc.zip.metadata.json
+
+ '
+ if-no-files-found: error
+ - name: Upload procedure report and command logs
+ if: ${{ always() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: procedures-nightly-package-windows-single
+ path: 'ci-report.json
+
+ ${{ runner.temp }}/wave-ci-*/*.log
+
+ '
+ if-no-files-found: warn
+ retention-days: 7
+ env:
+ RELEASE_VERSION: ${{ needs.gate.outputs.compiler_version }}
+ WAVE_NIGHTLY_CI_RUN: ${{ needs.gate.outputs.ci_run_id }}
+ package-windows-arm64:
+ name: Package Windows arm64 (MSVC)
+ needs:
+ - gate
+ runs-on: windows-11-vs2026-arm
+ timeout-minutes: 90
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ needs.gate.outputs.source_sha }}
+ persist-credentials: false
+ - uses: dtolnay/rust-toolchain@1.89.0
+ with:
+ toolchain: 1.89.0-aarch64-pc-windows-msvc
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+ architecture: arm64
+ - name: Build and validate Nightly package
+ id: procedure
+ env:
+ WAVE_CI_MATRIX: ${{ toJSON(matrix) }}
+ GH_TOKEN: ${{ github.token }}
+ run: python -m tools.ci.release --channel nightly --stage package --target "windows-arm64" --provision --report-json
+ ci-report.json
+ - name: Preserve native execution evidence
+ if: ${{ always() && steps.procedure.outputs.native_acceptance != 'skipped' }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: msvc-native-arm64-evidence
+ path: ${{ runner.temp }}/wave-msvc-native-arm64/
+ retention-days: 7
+ - name: Preserve case evidence
+ if: ${{ always() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: wave-cases-windows-arm64
+ path: wave-cases-windows-arm64.json
+ - name: Preserve package acceptance evidence
+ if: ${{ always() && steps.procedure.outputs.package_smoke != 'skipped' }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: msvc-package-arm64-evidence
+ path: ${{ runner.temp }}/wave-package-arm64/
+ retention-days: 7
+ - uses: actions/upload-artifact@v4
+ with:
+ name: nightly-windows-arm64
+ path: 'wave-v${{ needs.gate.outputs.compiler_version }}-aarch64-pc-windows-msvc.zip
+
+ wave-v${{ needs.gate.outputs.compiler_version }}-aarch64-pc-windows-msvc.zip.sha256
+
+ wave-v${{ needs.gate.outputs.compiler_version }}-aarch64-pc-windows-msvc.zip.metadata.json
+
+ '
+ if-no-files-found: error
+ - name: Upload procedure report and command logs
+ if: ${{ always() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: procedures-nightly-package-windows-arm64-single
+ path: 'ci-report.json
+
+ ${{ runner.temp }}/wave-ci-*/*.log
+
+ '
+ if-no-files-found: warn
+ retention-days: 7
+ env:
+ RELEASE_VERSION: ${{ needs.gate.outputs.compiler_version }}
+ WAVE_NIGHTLY_CI_RUN: ${{ needs.gate.outputs.ci_run_id }}
+ publish:
+ needs:
+ - gate
+ - package-linux
+ - package-linux-loongarch64
+ - package-linux-riscv64
+ - package-macos
+ - package-windows
+ - package-windows-arm64
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+ permissions:
+ contents: write
+ actions: read
+ concurrency:
+ group: wave-nightly-publish
+ cancel-in-progress: false
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ needs.gate.outputs.source_sha }}
+ persist-credentials: false
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+ - uses: actions/download-artifact@v4
+ with:
+ pattern: nightly-*
+ path: release-assets
+ merge-multiple: true
+ - name: Promote the complete Nightly generation
+ env:
+ GH_TOKEN: ${{ github.token }}
+ WAVE_NIGHTLY_CI_RUN: ${{ needs.gate.outputs.ci_run_id }}
+ run: python -m tools.ci.release --channel nightly --stage publish --publish
diff --git a/README.md b/README.md
index 999f4f41..e4ce6a0e 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@
-
+
diff --git a/examples/std/net_unix.wave b/examples/std/net_unix.wave
index 1ce387d9..d77887bc 100644
--- a/examples/std/net_unix.wave
+++ b/examples/std/net_unix.wave
@@ -1,4 +1,4 @@
-import("std::net::error")::{NET_ERROR_UNSUPPORTED, NetError, NetIoResult, NetResult};
+import("std::net::error")::{NetError, NetIoResult, NetResult};
import("std::net::unix")::{
UnixListener, UnixStream,
unix_listen, unix_connect, unix_accept,
@@ -7,20 +7,15 @@ import("std::net::unix")::{
};
#[target(os="linux")]
-fun main() -> i32 { return run_unix_stream(); }
+fun main() -> i32 { return run_unix_stream("/tmp/wave-std-net-unix.sock"); }
#[target(os="macos")]
-fun main() -> i32 { return run_unix_stream(); }
+fun main() -> i32 { return run_unix_stream("/tmp/wave-std-net-unix.sock"); }
#[target(os="freebsd")]
-fun main() -> i32 { return run_unix_stream(); }
+fun main() -> i32 { return run_unix_stream("/tmp/wave-std-net-unix.sock"); }
#[target(os="windows")]
-fun main() -> i32 {
- var unavailable: NetResult = unix_listen("wave-unavailable.sock", 1);
- if (unavailable.ok || unavailable.error.kind != NET_ERROR_UNSUPPORTED) { return 1; }
- return 0;
-}
+fun main() -> i32 { return run_unix_stream("wave-std-net-unix.sock"); }
-fun run_unix_stream() -> i32 {
- var path: str = "/tmp/wave-std-net-unix.sock";
+fun run_unix_stream(path: str) -> i32 {
unix_remove(path);
var listener: NetResult = unix_listen(path, 4);
diff --git a/std/net/error.wave b/std/net/error.wave
index 7fdc430d..3df1670d 100644
--- a/std/net/error.wave
+++ b/std/net/error.wave
@@ -109,7 +109,8 @@ fun classify_native_error(code: i64) -> i32 {
if (code == -10054) { return NET_ERROR_RESET; }
if (code == -10048) { return NET_ERROR_ADDRESS_IN_USE; }
if (code == -10057) { return NET_ERROR_NOT_CONNECTED; }
- if (code == -95 || code == -10045) { return NET_ERROR_UNSUPPORTED; }
+ if (code == -95 || code == -10043 || code == -10044
+ || code == -10045 || code == -10047) { return NET_ERROR_UNSUPPORTED; }
return NET_ERROR_OTHER;
}
diff --git a/std/net/unix.wave b/std/net/unix.wave
index ed3ab12c..629a7628 100644
--- a/std/net/unix.wave
+++ b/std/net/unix.wave
@@ -15,6 +15,8 @@ import("std::sys::fs")::{unlink};
import("std::sys::fs")::{unlink};
#[target(os="freebsd")]
import("std::sys::fs")::{unlink};
+#[target(os="windows")]
+import("std::sys::fs")::{unlink};
import("std::net::error")::{
NetError, NetResult, NetIoResult,
net_error_none, net_error_from_native,
@@ -54,7 +56,7 @@ fun _unix_socket() -> i64 {
#[target(os="freebsd")]
fun _unix_socket() -> i64 { return socket(AF_LOCAL, SOCK_STREAM, 0); }
#[target(os="windows")]
-fun _unix_socket() -> i64 { return -95; }
+fun _unix_socket() -> i64 { return socket(AF_LOCAL, SOCK_STREAM, 0); }
fun _unix_address(path: str, address: ptr) -> i32 {
return native_local_addr(path, address);
@@ -202,7 +204,13 @@ pub fun unix_remove(path: str) -> NetError {
return net_error_none();
}
#[target(os="windows")]
-pub fun unix_remove(path: str) -> NetError { return net_error_from_native(-95); }
+pub fun unix_remove(path: str) -> NetError {
+ // Removal is explicit, including a path left by successful bind followed
+ // by failed listen. Never remove an existing path automatically on bind.
+ var result: i64 = unlink(path);
+ if (result < 0) { return net_error_from_native(result); }
+ return net_error_none();
+}
pub fun unix_stream_valid(stream: UnixStream) -> bool { return net_fd_valid(stream.fd); }
pub fun unix_listener_valid(listener: UnixListener) -> bool { return net_fd_valid(listener.fd); }
diff --git a/std/sys/windows/event.wave b/std/sys/windows/event.wave
index 38b52c52..0c20afe8 100644
--- a/std/sys/windows/event.wave
+++ b/std/sys/windows/event.wave
@@ -6,6 +6,7 @@ import("std::sys::windows::socket")::{
POLLIN, POLLOUT, POLLERR, POLLHUP, POLLNVAL, PollFd, poll,
};
import("std::sys::windows::memory")::{sys_alloc, sys_free};
+extern(system, "Sleep") fun event_sleep(milliseconds: u32);
pub const EVENT_BACKEND_KIND: i32 = 3;
pub const EVENT_READABLE: i32 = 1;
@@ -89,6 +90,13 @@ pub fun event_wait(
|| capacity > WINDOWS_EVENT_CAPACITY || timeout_ms < -1) { return -22; }
var descriptors: array;
var count: i32 = deref state.count;
+ if (count == 0) {
+ // There is no concurrent registration/wakeup contract for this set.
+ // Sleep(INFINITE) blocks without spinning; WSAPoll rejects nfds=0.
+ if (timeout_ms == -1) { event_sleep(4294967295 as u32); }
+ if (timeout_ms > 0) { event_sleep(timeout_ms as u32); }
+ return 0;
+ }
var i: i32 = 0;
while (i < count) {
descriptors[i] = PollFd {
diff --git a/std/sys/windows/local_addr.wave b/std/sys/windows/local_addr.wave
index d42b8d77..ebbf0fdc 100644
--- a/std/sys/windows/local_addr.wave
+++ b/std/sys/windows/local_addr.wave
@@ -1,12 +1,43 @@
-// Unix-domain sockets are intentionally unavailable through this Unix API.
-// Windows named pipes and AF_UNIX require separate Windows-native contracts.
+// Windows 10 1803+ pathname AF_UNIX streams. The OS reports unavailable
+// providers through Winsock; abstract addresses are outside this API contract.
+// SOCKADDR_UN uses a u16 family followed by 108 UTF-8 bytes, including NUL.
+
+extern(system, "MultiByteToWideChar") fun local_path_to_wide(
+ page: u32, flags: u32, source: ptr, bytes: i32, output: ptr, capacity: i32
+) -> i32;
pub const AF_LOCAL: i32 = 1;
-pub const NATIVE_LOCAL_PATH_CAPACITY: i32 = 1;
+pub const NATIVE_LOCAL_PATH_CAPACITY: i32 = 108;
pub struct NativeLocalAddr {
- unavailable: u8;
+ family: u16;
+ path: array;
+}
+
+pub fun native_local_addr(path: str, output: ptr) -> i32 {
+ if (path as ptr == null || output == null) { return -22; }
+ var length: i32 = 0;
+ while (path[length] != 0) {
+ if (length >= NATIVE_LOCAL_PATH_CAPACITY - 1) { return -36; }
+ length += 1;
+ }
+ if (length == 0) { return -22; }
+ // Validate UTF-8 without allocating or changing the caller's output.
+ if (local_path_to_wide(65001, 8, path as ptr, length, null, 0) == 0) {
+ return -84;
+ }
+ var value: NativeLocalAddr;
+ value.family = AF_LOCAL as u16;
+ var i: i32 = 0;
+ while (i < NATIVE_LOCAL_PATH_CAPACITY) {
+ value.path[i] = 0;
+ if (i < length) { value.path[i] = path[i]; }
+ i += 1;
+ }
+ deref output = value;
+ return length + 3;
}
-pub fun native_local_addr(path: str, output: ptr) -> i32 { return -95; }
-pub fun native_local_abstract(name: str, output: ptr) -> i32 { return -95; }
+pub fun native_local_abstract(name: str, output: ptr) -> i32 {
+ return -95;
+}
diff --git a/tests/boundary_regressions.rs b/tests/boundary_regressions.rs
index 56b871e4..5c1a63c0 100644
--- a/tests/boundary_regressions.rs
+++ b/tests/boundary_regressions.rs
@@ -234,3 +234,44 @@ fn read_to_end_rejects_capacity_overflow_and_preserves_normal_growth() {
fs::write(case.root.join("empty.bin"), []).unwrap();
case.run(&source("tests/fixtures/boundaries/read_to_end.wave"));
}
+#[test]
+#[cfg(windows)]
+fn empty_windows_event_set_returns_zero() {
+ let case = Case::new("empty-windows-event");
+
+ let path = case.root.join("empty_event.wave");
+
+ fs::write(
+ &path,
+ r#"
+import("std::sys::event")::{
+ NativeEvent, event_create, event_wait, event_close
+};
+
+fun main() -> i32 {
+ var handle: i64 = event_create(4);
+ if (handle < 0) { return 1; }
+
+ var events: array;
+ var count: i64 = event_wait(handle, &events[0], 4, 0);
+
+ event_close(handle);
+
+ if (count != 0) { return 2; }
+ return 0;
+}
+"#,
+ )
+ .unwrap();
+
+ success(
+ case.command()
+ .arg("--std-root")
+ .arg(case.home.join(".wave/lib/wave/std"))
+ .arg("build")
+ .arg(&path)
+ .arg("--run")
+ .output()
+ .unwrap(),
+ );
+}
diff --git a/tests/fixtures/native_providers/network_errors.wave b/tests/fixtures/native_providers/network_errors.wave
index d88f630c..a7d92891 100644
--- a/tests/fixtures/native_providers/network_errors.wave
+++ b/tests/fixtures/native_providers/network_errors.wave
@@ -88,6 +88,9 @@ fun platform() -> i32 {
if (!check(-10057, NET_ERROR_NOT_CONNECTED)) { return 58; }
if (!check(-95, NET_ERROR_UNSUPPORTED)) { return 96; }
if (!check(-10045, NET_ERROR_UNSUPPORTED)) { return 46; }
+ if (!check(-10043, NET_ERROR_UNSUPPORTED)) { return 47; }
+ if (!check(-10044, NET_ERROR_UNSUPPORTED)) { return 48; }
+ if (!check(-10047, NET_ERROR_UNSUPPORTED)) { return 49; }
if (!check(-4, NET_ERROR_OTHER)) { return 201; }
if (!check(-11, NET_ERROR_OTHER)) { return 201; }
if (!check(-35, NET_ERROR_OTHER)) { return 201; }
diff --git a/tests/fixtures/native_providers/windows_event.wave b/tests/fixtures/native_providers/windows_event.wave
new file mode 100644
index 00000000..29cf1a72
--- /dev/null
+++ b/tests/fixtures/native_providers/windows_event.wave
@@ -0,0 +1,25 @@
+// SPDX-License-Identifier: MPL-2.0
+import("std::sys::event")::{NativeEvent, event_create, event_add, event_remove, event_wait, event_close, EVENT_READABLE};
+import("std::sys::socket")::{socket, bind, close_socket, AF_INET, SOCK_DGRAM};
+struct Address { family: u16; port: u16; ip: array; padding: array; }
+extern(system, "GetTickCount64") fun tick() -> u64;
+fun empty_wait(handle: i64) -> bool {
+ var output: NativeEvent;
+ if (event_wait(handle, &output, 1, 0) != 0) { return false; }
+ var start: u64 = tick();
+ if (event_wait(handle, &output, 1, 150) != 0) { return false; }
+ // Allow Windows clock granularity, but reject an immediate return.
+ return tick() - start >= 120;
+}
+fun main() -> i32 {
+ var handle: i64 = event_create(1);
+ if (handle < 0 || !empty_wait(handle)) { return 1; }
+ var fd: i64 = socket(AF_INET, SOCK_DGRAM, 0);
+ var address: Address = Address { family: 2, port: 0, ip: [127, 0, 0, 1], padding: [0,0,0,0,0,0,0,0] };
+ if (fd < 0 || bind(fd, &address as ptr, 16) != 0 || event_add(handle, fd, 42, EVENT_READABLE) != 0) { return 2; }
+ var output: NativeEvent;
+ if (event_wait(handle, &output, 1, 0) < 0) { return 3; }
+ if (event_remove(handle, fd) != 0 || !empty_wait(handle)) { return 4; }
+ if (close_socket(fd) != 0 || event_close(handle) != 0) { return 5; }
+ return 0;
+}
diff --git a/tests/fixtures/native_providers/windows_event_infinite.wave b/tests/fixtures/native_providers/windows_event_infinite.wave
new file mode 100644
index 00000000..9c7b4e34
--- /dev/null
+++ b/tests/fixtures/native_providers/windows_event_infinite.wave
@@ -0,0 +1,12 @@
+// SPDX-License-Identifier: MPL-2.0
+import("std::sys::event")::{NativeEvent, event_create, event_wait};
+import("std::sys::fs")::{open, close, FS_O_CREAT, FS_O_EXCL, FS_O_WRONLY};
+fun main() -> i32 {
+ var handle: i64 = event_create(1);
+ if (handle < 0) { return 1; }
+ var fd: i64 = open("ready", FS_O_CREAT | FS_O_EXCL | FS_O_WRONLY, 0);
+ if (fd < 0 || close(fd) != 0) { return 2; }
+ var output: NativeEvent;
+ event_wait(handle, &output, 1, -1);
+ return 3;
+}
diff --git a/tests/fixtures/native_providers/windows_event_mock.c b/tests/fixtures/native_providers/windows_event_mock.c
new file mode 100644
index 00000000..745d2d53
--- /dev/null
+++ b/tests/fixtures/native_providers/windows_event_mock.c
@@ -0,0 +1,19 @@
+// SPDX-License-Identifier: MPL-2.0
+#include
+#include
+#include
+#include
+static int sleeps, polls;
+static uint32_t milliseconds;
+void reset_calls(void) { sleeps = polls = 0; milliseconds = 0; }
+bool calls_ok(int s, uint32_t ms, int p) { return sleeps == s && milliseconds == ms && polls == p; }
+void Sleep(uint32_t ms) { sleeps++; milliseconds = ms; }
+void *sys_alloc(int64_t size) { return calloc(1, (size_t)size); }
+int64_t sys_free(void *p, int64_t size) { (void)size; free(p); return 0; }
+struct pollfd { int64_t fd; int16_t events, revents; };
+int64_t poll(struct pollfd *fds, int64_t count, int32_t timeout) {
+ polls++;
+ assert(count == 1 && timeout == 12 && fds[0].fd == 17 && fds[0].events == 256);
+ fds[0].revents = 256;
+ return 1;
+}
diff --git a/tests/fixtures/native_providers/windows_event_mock.wave b/tests/fixtures/native_providers/windows_event_mock.wave
new file mode 100644
index 00000000..bcf1507e
--- /dev/null
+++ b/tests/fixtures/native_providers/windows_event_mock.wave
@@ -0,0 +1,27 @@
+// SPDX-License-Identifier: MPL-2.0
+extern(c) fun reset_calls();
+extern(c) fun calls_ok(sleeps: i32, milliseconds: u32, polls: i32) -> bool;
+fun verify_empty(handle: i64) -> bool {
+ var output: NativeEvent;
+ output.token = 987;
+ reset_calls();
+ if (event_wait(handle, &output, 1, 0) != 0 || !calls_ok(0, 0, 0) || output.token != 987) { return false; }
+ if (event_wait(handle, &output, 1, 123) != 0 || !calls_ok(1, 123, 0)) { return false; }
+ reset_calls();
+ if (event_wait(handle, &output, 1, -1) != 0 || !calls_ok(1, 4294967295 as u32, 0)) { return false; }
+ reset_calls();
+ if (event_wait(handle, &output, 1, -2) != -22 || event_wait(handle, null, 1, 0) != -22
+ || !calls_ok(0, 0, 0)) { return false; }
+ return true;
+}
+fun main() -> i32 {
+ var handle: i64 = event_create(1);
+ if (handle < 0 || !verify_empty(handle)) { return 1; }
+ if (event_add(handle, 17, 42, EVENT_READABLE) != 0) { return 2; }
+ var output: NativeEvent;
+ reset_calls();
+ if (event_wait(handle, &output, 1, 12) != 1 || output.token != 42
+ || output.fd != 17 || output.flags != EVENT_READABLE || !calls_ok(0, 0, 1)) { return 3; }
+ if (event_remove(handle, 17) != 0 || !verify_empty(handle) || event_close(handle) != 0) { return 4; }
+ return 0;
+}
diff --git a/tests/fixtures/native_providers/windows_local_addr.wave b/tests/fixtures/native_providers/windows_local_addr.wave
new file mode 100644
index 00000000..c9a812a2
--- /dev/null
+++ b/tests/fixtures/native_providers/windows_local_addr.wave
@@ -0,0 +1,28 @@
+// SPDX-License-Identifier: MPL-2.0
+import("std::sys::windows::local_addr")::{
+ AF_LOCAL, NativeLocalAddr, native_local_addr, native_local_abstract
+};
+import("std::mem::layout")::{size_of};
+fun main() -> i32 {
+ var address: NativeLocalAddr;
+ if (AF_LOCAL != 1 || size_of() != 110) { return 1; }
+ if (native_local_addr("한글.sock", &address) != 14 || address.family != 1
+ || address.path[0] != 237 || address.path[11] != 0 || address.path[107] != 0) { return 2; }
+ var path: array;
+ var i: i32 = 0;
+ while (i < 108) { path[i] = 97; i += 1; }
+ path[107] = 0;
+ if (native_local_addr(&path[0] as str, &address) != 110 || address.path[106] != 97
+ || address.path[107] != 0) { return 3; }
+ address.family = 99;
+ path[107] = 97; path[108] = 0;
+ if (native_local_addr(&path[0] as str, &address) != -36 || address.family != 99) { return 4; }
+ var nil: ptr = null;
+ if (native_local_addr("", &address) != -22 || address.family != 99
+ || native_local_addr(nil as str, &address) != -22
+ || native_local_addr("ok", null) != -22) { return 5; }
+ var invalid: array = [192, 175, 0];
+ if (native_local_addr(&invalid[0] as str, &address) != -84 || address.family != 99) { return 6; }
+ if (native_local_abstract("name", &address) != -95 || address.family != 99) { return 7; }
+ return 0;
+}
diff --git a/tests/fixtures/native_providers/windows_local_addr_mock.c b/tests/fixtures/native_providers/windows_local_addr_mock.c
new file mode 100644
index 00000000..5ce92439
--- /dev/null
+++ b/tests/fixtures/native_providers/windows_local_addr_mock.c
@@ -0,0 +1,10 @@
+// SPDX-License-Identifier: MPL-2.0
+#include
+#include
+int MultiByteToWideChar(uint32_t page, uint32_t flags, const unsigned char *s,
+ int bytes, uint16_t *output, int capacity) {
+ assert(page == 65001 && flags == 8 && bytes > 0 && bytes <= 107);
+ assert(output == 0 && capacity == 0);
+ // Boundary mock: native Windows tests validate the actual UTF-8 decoder.
+ return s[0] == 192 ? 0 : bytes;
+}
diff --git a/tests/fixtures/native_providers/windows_local_addr_mock.wave b/tests/fixtures/native_providers/windows_local_addr_mock.wave
new file mode 100644
index 00000000..95a7473f
--- /dev/null
+++ b/tests/fixtures/native_providers/windows_local_addr_mock.wave
@@ -0,0 +1,26 @@
+// SPDX-License-Identifier: MPL-2.0
+
+import("std::mem::layout")::{size_of};
+fun main() -> i32 {
+ var address: NativeLocalAddr;
+ if (AF_LOCAL != 1 || size_of() != 110) { return 1; }
+ if (native_local_addr("한글.sock", &address) != 14 || address.family != 1
+ || address.path[0] != 237 || address.path[11] != 0 || address.path[107] != 0) { return 2; }
+ var path: array;
+ var i: i32 = 0;
+ while (i < 108) { path[i] = 97; i += 1; }
+ path[107] = 0;
+ if (native_local_addr(&path[0] as str, &address) != 110 || address.path[106] != 97
+ || address.path[107] != 0) { return 3; }
+ address.family = 99;
+ path[107] = 97; path[108] = 0;
+ if (native_local_addr(&path[0] as str, &address) != -36 || address.family != 99) { return 4; }
+ var nil: ptr = null;
+ if (native_local_addr("", &address) != -22 || address.family != 99
+ || native_local_addr(nil as str, &address) != -22
+ || native_local_addr("ok", null) != -22) { return 5; }
+ var invalid: array = [192, 175, 0];
+ if (native_local_addr(&invalid[0] as str, &address) != -84 || address.family != 99) { return 6; }
+ if (native_local_abstract("name", &address) != -95 || address.family != 99) { return 7; }
+ return 0;
+}
diff --git a/tests/fixtures/native_providers/windows_unix.wave b/tests/fixtures/native_providers/windows_unix.wave
new file mode 100644
index 00000000..e189c43c
--- /dev/null
+++ b/tests/fixtures/native_providers/windows_unix.wave
@@ -0,0 +1,65 @@
+// SPDX-License-Identifier: MPL-2.0
+import("std::net::unix")::{
+ UnixListener, UnixStream, unix_listen, unix_connect, unix_accept,
+ unix_read, unix_write_all, unix_shutdown_write, unix_close,
+ unix_close_listener, unix_remove, unix_listen_abstract
+};
+import("std::net::error")::{NetResult, NetIoResult, NET_ERROR_UNSUPPORTED};
+import("std::sys::fs")::{access, open, close, write, read, FS_O_CREAT, FS_O_EXCL, FS_O_RDWR};
+
+fun exchange(path: str) -> i32 {
+ var listener: NetResult = unix_listen(path, 4);
+ if (!listener.ok) { return 10; }
+ var occupied: NetResult = unix_listen(path, 4);
+ if (occupied.ok || occupied.error.native_code >= 0) { return 11; }
+ var client: NetResult = unix_connect(path);
+ if (!client.ok) { return 12; }
+ var accepted: NetResult = unix_accept(listener.value);
+ if (!accepted.ok) { return 13; }
+ var bytes: array = [10, 20, 30];
+ var sent: NetIoResult = unix_write_all(client.value, &bytes[0], 3);
+ if (sent.error.kind != 0 || sent.count != 3) { return 14; }
+ var got: array;
+ var count: i64 = 0;
+ while (count < 3) {
+ var received: NetIoResult = unix_read(accepted.value, &got[count], 3 - count);
+ if (received.error.kind != 0 || received.count <= 0) { return 15; }
+ count += received.count;
+ }
+ if (got[0] != 10 || got[1] != 20 || got[2] != 30) { return 16; }
+ if (unix_shutdown_write(client.value).kind != 0) { return 17; }
+ var eof: NetIoResult = unix_read(accepted.value, &got[0], 1);
+ if (!eof.eof || eof.count != 0) { return 18; }
+ if (unix_close(client.value).kind != 0 || unix_close(accepted.value).kind != 0
+ || unix_close_listener(listener.value).kind != 0) { return 19; }
+ if (access(path, 0) != 0 || unix_remove(path).kind != 0 || access(path, 0) == 0) { return 20; }
+ listener = unix_listen(path, 1);
+ if (!listener.ok || unix_close_listener(listener.value).kind != 0
+ || unix_remove(path).kind != 0) { return 21; }
+ return 0;
+}
+fun main() -> i32 {
+ var missing: NetResult = unix_connect("missing.sock");
+ if (missing.ok || missing.error.native_code >= 0) { return 1; }
+ var bad_parent: NetResult = unix_listen("missing-dir/socket", 1);
+ if (bad_parent.ok || bad_parent.error.native_code >= 0) { return 2; }
+ var abstract: NetResult = unix_listen_abstract("name", 1);
+ if (abstract.ok || abstract.error.kind != NET_ERROR_UNSUPPORTED) { return 3; }
+ var file: i64 = open("ordinary-file", FS_O_CREAT | FS_O_EXCL | FS_O_RDWR, 0);
+ var marker: u8 = 42;
+ if (file < 0 || write(file, &marker, 1) != 1 || close(file) != 0) { return 4; }
+ var occupied: NetResult = unix_listen("ordinary-file", 1);
+ if (occupied.ok) { return 5; }
+ file = open("ordinary-file", 0, 0);
+ marker = 0;
+ if (file < 0 || read(file, &marker, 1) != 1 || marker != 42 || close(file) != 0) { return 6; }
+ var result: i32 = exchange("ascii.sock");
+ if (result != 0) { return result; }
+ result = exchange("한글.sock");
+ if (result != 0) { return result; }
+ var path: array;
+ var i: i32 = 0;
+ while (i < 107) { path[i] = 97; i += 1; }
+ path[107] = 0;
+ return exchange(&path[0] as str);
+}
diff --git a/tests/fixtures/native_providers/windows_unix_mock.c b/tests/fixtures/native_providers/windows_unix_mock.c
new file mode 100644
index 00000000..9997bccd
--- /dev/null
+++ b/tests/fixtures/native_providers/windows_unix_mock.c
@@ -0,0 +1,37 @@
+// SPDX-License-Identifier: MPL-2.0
+#include
+#include
+#include
+static int failure, closed, sockets;
+void prepare_failure(int stage) { failure = stage; closed = sockets = 0; }
+int closed_count(void) { return closed; }
+int socket_count(void) { return sockets; }
+int MultiByteToWideChar(uint32_t page, uint32_t flags, const char *text, int bytes, void *out, int cap) {
+ assert(page == 65001 && flags == 8 && bytes == 9 && !out && cap == 0);
+ assert(memcmp(text, "unit.sock", 9) == 0);
+ return bytes;
+}
+int64_t socket(int domain, int type, int protocol) {
+ assert(domain == 1 && type == 1 && protocol == 0);
+ sockets++;
+ return failure == 1 ? -10047 : 71;
+}
+struct address { uint16_t family; char path[108]; };
+static void check_address(int64_t fd, const struct address *a, int length) {
+ assert(fd == 71 && length == 12 && a->family == 1);
+ assert(strcmp(a->path, "unit.sock") == 0 && a->path[107] == 0);
+}
+int64_t bind(int64_t fd, const struct address *a, int length) {
+ check_address(fd, a, length); return failure == 2 ? -10048 : 0;
+}
+int64_t listen(int64_t fd, int backlog) {
+ assert(fd == 71 && backlog == 1); return failure == 3 ? -10022 : 0;
+}
+int64_t connect(int64_t fd, const struct address *a, int length) {
+ check_address(fd, a, length); return failure == 4 ? -10061 : 0;
+}
+int64_t net_close(int64_t fd) {
+ assert(fd == 71); closed++;
+ // Even a cleanup error must not replace the original operation's error.
+ return -10038;
+}
diff --git a/tests/fixtures/native_providers/windows_unix_mock.wave b/tests/fixtures/native_providers/windows_unix_mock.wave
new file mode 100644
index 00000000..cfc40bd4
--- /dev/null
+++ b/tests/fixtures/native_providers/windows_unix_mock.wave
@@ -0,0 +1,29 @@
+// SPDX-License-Identifier: MPL-2.0
+extern(c) fun prepare_failure(stage: i32);
+extern(c) fun closed_count() -> i32;
+extern(c) fun socket_count() -> i32;
+fun main() -> i32 {
+ prepare_failure(0);
+ var invalid: NetResult = unix_listen("", 1);
+ if (invalid.ok || invalid.error.native_code != -22 || socket_count() != 0) { return 1; }
+ invalid = unix_listen("unit.sock", -1);
+ if (invalid.ok || invalid.error.native_code != -22 || socket_count() != 0) { return 2; }
+ prepare_failure(1);
+ var listener: NetResult = unix_listen("unit.sock", 1);
+ if (listener.ok || listener.error.native_code != -10047 || closed_count() != 0) { return 3; }
+ prepare_failure(2);
+ listener = unix_listen("unit.sock", 1);
+ if (listener.ok || listener.error.native_code != -10048 || closed_count() != 1) { return 4; }
+ prepare_failure(3);
+ listener = unix_listen("unit.sock", 1);
+ if (listener.ok || listener.error.native_code != -10022 || closed_count() != 1) { return 5; }
+ prepare_failure(4);
+ var stream: NetResult = unix_connect("unit.sock");
+ if (stream.ok || stream.error.native_code != -10061 || closed_count() != 1) { return 6; }
+ prepare_failure(0);
+ listener = unix_listen("unit.sock", 1);
+ stream = unix_connect("unit.sock");
+ if (!listener.ok || !stream.ok || listener.value.fd != 71 || stream.value.fd != 71
+ || socket_count() != 2 || closed_count() != 0) { return 7; }
+ return 0;
+}
diff --git a/tests/native_providers.rs b/tests/native_providers.rs
index 261c2649..57d675ee 100644
--- a/tests/native_providers.rs
+++ b/tests/native_providers.rs
@@ -98,6 +98,62 @@ fn host_target() -> String {
format!("{}-{os}", std::env::consts::ARCH)
}
+#[test]
+fn windows_pathname_streams_and_empty_events() {
+ let case = Case::new();
+ for target in ["x86_64-pc-windows-msvc", "aarch64-pc-windows-msvc"] {
+ if !supported(target) {
+ continue;
+ }
+ for name in [
+ "windows_local_addr.wave",
+ "windows_unix.wave",
+ "windows_event.wave",
+ "windows_event_infinite.wave",
+ ] {
+ for opt in ["-O0", "-O2"] {
+ let native = target == host_target();
+ let output = case.0.join(if native { "ipc.exe" } else { "ipc.o" });
+ build(&case, &fixture(name), target, opt, &output, !native);
+ if native && name != "windows_event_infinite.wave" {
+ let run_dir = case.0.join(format!("{name}-{opt}"));
+ fs::create_dir(&run_dir).unwrap();
+ checked(Command::new(&output).current_dir(&run_dir), name, &case.0);
+ }
+ }
+ }
+ }
+}
+
+#[cfg(windows)]
+#[test]
+fn windows_empty_infinite_wait_blocks_until_process_is_stopped() {
+ let case = Case::new();
+ // A marker proves the child reached the wait, rather than merely starting slowly.
+ let source = fixture("windows_event_infinite.wave");
+ for opt in ["-O0", "-O2"] {
+ let binary = case.0.join("infinite.exe");
+ build(&case, &source, &host_target(), opt, &binary, false);
+ let mut child = Command::new(binary).current_dir(&case.0).spawn().unwrap();
+ let deadline = Instant::now() + Duration::from_secs(10);
+ let marker = case.0.join("ready");
+ while !marker.exists() && Instant::now() < deadline {
+ if let Some(status) = child.try_wait().unwrap() {
+ panic!("infinite wait exited before readiness: {status}");
+ }
+ std::thread::sleep(Duration::from_millis(10));
+ }
+ let ready = marker.exists();
+ std::thread::sleep(Duration::from_millis(250));
+ let premature = child.try_wait().unwrap();
+ let _ = child.kill();
+ let _ = child.wait();
+ assert!(ready, "child did not reach the wait");
+ assert!(premature.is_none(), "infinite wait returned: {premature:?}");
+ fs::remove_file(marker).unwrap();
+ }
+}
+
#[test]
fn loongarch_attribute_aliases_select_identical_declarations() {
let target = "loongarch64-unknown-linux-gnu";
@@ -206,6 +262,9 @@ fn native_provider_os_boundary_failures() {
let case = Case::new();
for (name, provider) in [
("windows_time", "std/sys/windows/time.wave"),
+ ("windows_local_addr", "std/sys/windows/local_addr.wave"),
+ ("windows_unix", "std/sys/windows/local_addr.wave"),
+ ("windows_event", "std/sys/windows/event.wave"),
("windows_random", "std/sys/windows/random.wave"),
("freebsd_random", "std/sys/freebsd/amd64/random.wave"),
("macos_event", "std/sys/macos/event.wave"),
@@ -213,6 +272,39 @@ fn native_provider_os_boundary_failures() {
let mut text = fs::read_to_string(root().join(provider))
.unwrap()
.replace("extern(system,", "extern(c,");
+ if name == "windows_unix" {
+ let unix = fs::read_to_string(root().join("std/net/unix.wave")).unwrap();
+ text += "\nimport(\"std::net::error\")::{NetResult, NetError, net_error_from_native, net_result_err, net_result_ok};\n";
+ let types = unix.find("pub struct UnixListener").unwrap();
+ let end_types = unix[types..].find("#[target").unwrap() + types;
+ text += &unix[types..end_types];
+ let windows = unix
+ .find("#[target(os=\"windows\")]\nfun _unix_socket")
+ .unwrap();
+ let start = windows + "#[target(os=\"windows\")]\n".len();
+ let end = unix[start..].find('\n').unwrap() + start;
+ text += &unix[start..end];
+ let start = unix.find("fun _unix_address").unwrap();
+ let end = unix[start..].find("#[target").unwrap() + start;
+ text += &unix[start..end];
+ text += r#"
+const SOCK_STREAM: i32 = 1;
+extern(c) fun socket(domain: i32, ty: i32, protocol: i32) -> i64;
+extern(c) fun bind(fd: i64, address: ptr, length: i32) -> i64;
+extern(c) fun listen(fd: i64, backlog: i32) -> i64;
+extern(c) fun connect(fd: i64, address: ptr, length: i32) -> i64;
+extern(c) fun net_close(fd: i64) -> i64;
+"#;
+ }
+ if name == "windows_event" {
+ text = text.replace(
+ "import(\"std::sys::windows::socket\")::{\n POLLIN, POLLOUT, POLLERR, POLLHUP, POLLNVAL, PollFd, poll,\n};",
+ "pub const POLLIN: i16 = 256; pub const POLLOUT: i16 = 16; pub const POLLERR: i16 = 1; pub const POLLHUP: i16 = 2; pub const POLLNVAL: i16 = 4; struct PollFd { fd: i64; events: i16; revents: i16; } extern(c) fun poll(fds: ptr, count: i64, timeout: i32) -> i64;",
+ ).replace(
+ "import(\"std::sys::windows::memory\")::{sys_alloc, sys_free};",
+ "extern(c) fun sys_alloc(size: i64) -> ptr; extern(c) fun sys_free(p: ptr, size: i64) -> i64;",
+ );
+ }
if name == "freebsd_random" {
text = text.replace(
"import(\"std::sys::freebsd::amd64::syscall\")::{syscall3};",
@@ -253,6 +345,8 @@ fn native_provider_os_boundary_failures() {
let binary = case.0.join("probe");
checked(
Command::new("clang")
+ // Wave emits static objects; Ubuntu's clang defaults to PIE.
+ .arg("-no-pie")
.arg(&object)
.arg(&c_object)
.arg("-o")
diff --git a/tools/ci/nightly.py b/tools/ci/nightly.py
new file mode 100644
index 00000000..b33bdc8a
--- /dev/null
+++ b/tools/ci/nightly.py
@@ -0,0 +1,459 @@
+# SPDX-License-Identifier: MPL-2.0
+"""Rolling Nightly identity, staging and recoverable GitHub publication.
+
+The release body's state marker is the committed generation. Assets are immutable
+and verified before the tag/body promotion. A retry repairs a tag left ahead of
+that marker by an interrupted promotion; old assets survive until commit.
+"""
+
+import argparse
+import base64
+from datetime import datetime, timezone
+import hashlib
+import json
+import os
+from pathlib import Path
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+import tomllib
+from urllib.request import urlopen
+
+from tools.ci.common import ROOT
+from tools.ci.release import verify_metadata
+
+REPO = "wavefnd/Wave"
+SHA = r"[0-9a-f]{40}"
+MARKER = r""
+INSTALLERS_FILE = Path(__file__).with_name("nightly_installers.json")
+
+
+def sha(value):
+ if not isinstance(value, str) or not re.fullmatch(SHA, value):
+ raise ValueError("expected a full source SHA")
+ return value
+
+
+class GitHub:
+ def api(self, endpoint, *, method="GET", data=None, optional=False):
+ args = ["gh", "api", f"repos/{REPO}/{endpoint}", "--method", method]
+ if data is not None:
+ args += ["--input", "-"]
+ result = subprocess.run(
+ args,
+ input=json.dumps(data) if data is not None else None,
+ text=True,
+ capture_output=True,
+ timeout=120,
+ )
+ if result.returncode:
+ if optional and "HTTP 404" in result.stderr:
+ return None
+ raise RuntimeError(f"GitHub {method} {endpoint}: {result.stderr.strip()}")
+ return json.loads(result.stdout) if result.stdout.strip() else None
+
+ def assets(self, release_id):
+ result = []
+ page = 1
+ while True:
+ batch = self.api(f"releases/{release_id}/assets?per_page=100&page={page}")
+ result.extend(batch)
+ if len(batch) < 100:
+ return result
+ page += 1
+
+ def upload(self, path):
+ # Never --clobber: an existing usable asset must not be deleted first.
+ subprocess.run(
+ ["gh", "release", "upload", "nightly", str(path), "--repo", REPO],
+ check=True,
+ timeout=1800,
+ )
+
+
+def ancestor(github, older, newer):
+ sha(older)
+ sha(newer)
+ if older == newer:
+ return True
+ comparison = github.api(f"compare/{older}...{newer}")
+ return (
+ comparison["status"] == "ahead"
+ and comparison["merge_base_commit"]["sha"] == older
+ )
+
+
+def eligible(github, run_id):
+ if not str(run_id).isdigit() or int(run_id) <= 0:
+ raise ValueError("a successful canonical master CI run ID is required")
+ run = github.api(f"actions/runs/{run_id}")
+ if (
+ run.get("repository", {}).get("full_name") != REPO
+ or run.get("head_repository", {}).get("full_name") != REPO
+ or run.get("event") != "push"
+ or run.get("head_branch") != "master"
+ or run.get("path") != ".github/workflows/ci.yml"
+ or run.get("status") != "completed"
+ or run.get("conclusion") != "success"
+ ):
+ raise ValueError("Nightly requires successful canonical master push CI")
+ jobs = []
+ page = 1
+ while True:
+ batch = github.api(
+ f"actions/runs/{run_id}/attempts/{run['run_attempt']}/jobs?per_page=100&page={page}"
+ )["jobs"]
+ jobs.extend(batch)
+ if len(batch) < 100:
+ break
+ page += 1
+ if not jobs or any(
+ j.get("status") != "completed" or j.get("conclusion") != "success" for j in jobs
+ ):
+ raise ValueError(
+ "CI jobs must all complete successfully; skipped jobs are not validation"
+ )
+ source = sha(run["head_sha"])
+ master = github.api("git/ref/heads/master")["object"]["sha"]
+ if not ancestor(github, source, master):
+ raise ValueError("CI source is not in canonical master history")
+ content = github.api(f"contents/Cargo.toml?ref={source}")
+ version = tomllib.loads(base64.b64decode(content["content"]).decode())["package"][
+ "version"
+ ]
+ if not re.fullmatch(r"\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?", version):
+ raise ValueError("invalid compiler version")
+ return {"source_sha": source, "compiler_version": version, "ci_run_id": int(run_id)}
+
+
+def checkout_identity(identity):
+ source = subprocess.run(
+ ["git", "rev-parse", "HEAD"],
+ cwd=ROOT,
+ check=True,
+ capture_output=True,
+ text=True,
+ ).stdout.strip()
+ version = tomllib.loads((ROOT / "Cargo.toml").read_text())["package"]["version"]
+ if source != identity["source_sha"] or version != identity["compiler_version"]:
+ raise ValueError("checkout differs from the exact CI-validated source/version")
+
+
+def installers_ready():
+ # The installer change lives in wavefnd/wave-platform. Do not publish until
+ # those reviewed bytes are actually deployed, regardless of merge order.
+ pins = json.loads(INSTALLERS_FILE.read_text())
+ required = {"https://wave-lang.dev/install.sh", "https://wave-lang.dev/install.ps1"}
+ if set(pins) != required or any(
+ not re.fullmatch(r"[0-9a-f]{64}", value) for value in pins.values()
+ ):
+ raise ValueError("both reviewed installer deployment hashes are required")
+ for url, expected in pins.items():
+ with urlopen(url, timeout=30) as response:
+ data = response.read(1024 * 1024 + 1)
+ if len(data) > 1024 * 1024 or hashlib.sha256(data).hexdigest() != expected:
+ raise ValueError(f"versioned-only installer is not deployed: {url}")
+
+
+def file_record(path):
+ with path.open("rb") as stream:
+ digest = hashlib.file_digest(stream, "sha256").hexdigest()
+ return {"sha256": digest, "size": path.stat().st_size}
+
+
+def stage(directory, output, identity, run_id, attempt, revision):
+ if (
+ not str(run_id).isdigit()
+ or not str(attempt).isdigit()
+ or min(int(run_id), int(attempt)) < 1
+ ):
+ raise ValueError("invalid Nightly workflow run identity")
+ source, version = sha(identity["source_sha"]), identity["compiler_version"]
+ verify_metadata(directory, version, source, revision)
+ generation = f"{source}-{run_id}-{attempt}"
+ output.mkdir()
+ records = {}
+ checksums = []
+ for descriptor in sorted(directory.glob("*.metadata.json")):
+ metadata = json.loads(descriptor.read_text())
+ original = metadata["archive"]
+ name = original.replace(f"wave-v{version}-", f"wave-nightly-{generation}-", 1)
+ archive = output / name
+ shutil.copyfile(directory / original, archive)
+ if file_record(archive)["sha256"] != metadata["sha256"]:
+ raise ValueError("package changed while staging Nightly")
+ metadata.update(archive=name, channel="nightly", generation=generation)
+ (output / (name + ".metadata.json")).write_text(
+ json.dumps(metadata, indent=2) + "\n"
+ )
+ line = f"{metadata['sha256']} {name}\n"
+ (output / (name + ".sha256")).write_text(line)
+ checksums.append(line)
+ (output / f"nightly-{generation}-SHA256SUMS").write_text("".join(checksums))
+ for path in sorted(output.iterdir()):
+ records[path.name] = file_record(path)
+ state = dict(
+ identity,
+ schema_version=1,
+ generation=generation,
+ run_id=int(run_id),
+ built_at=datetime.now(timezone.utc).isoformat(),
+ assets=records,
+ )
+ manifest = output / f"nightly-{generation}.json"
+ manifest.write_text(json.dumps(state, indent=2) + "\n")
+ state["assets"][manifest.name] = file_record(manifest)
+ return state
+
+
+def active_state(release):
+ if release is None:
+ return None
+ if (
+ release.get("tag_name") != "nightly"
+ or release.get("name") != "Wave Nightly"
+ or not release.get("prerelease")
+ ):
+ raise ValueError("refusing to replace an unmanaged Nightly release")
+ match = re.search(MARKER, release.get("body") or "")
+ if match is None:
+ if release.get("draft") and not release.get("body"):
+ return None # Interrupted first publication, before promotion.
+ raise ValueError("Nightly release has no recovery state")
+ state = json.loads(match[1])
+ sha(state["source_sha"])
+ if state.get("schema_version") != 1 or not state.get("assets"):
+ raise ValueError("invalid Nightly recovery state")
+ return state
+
+
+def verify_remote(github, release_id, state):
+ assets = {a["name"]: a for a in github.assets(release_id)}
+ for name, record in state["assets"].items():
+ asset = assets.get(name, {})
+ if (
+ asset.get("state") != "uploaded"
+ or asset.get("size") != record["size"]
+ or asset.get("digest") != "sha256:" + record["sha256"]
+ ):
+ raise ValueError(f"unverified Nightly asset: {name}")
+
+
+def set_tag(github, source):
+ sha(source)
+ tag = github.api("git/ref/tags/nightly", optional=True)
+ if tag is None:
+ github.api(
+ "git/refs", method="POST", data={"ref": "refs/tags/nightly", "sha": source}
+ )
+ elif tag["object"]["sha"] != source:
+ if tag["object"]["type"] != "commit":
+ raise ValueError("Nightly must use a lightweight commit tag")
+ github.api(
+ "git/refs/tags/nightly", method="PATCH", data={"sha": source, "force": True}
+ )
+
+
+def notes(state):
+ source = state["source_sha"]
+ lines = [
+ "Wave Nightly",
+ "",
+ f"Commit: [{source}](https://github.com/{REPO}/commit/{source})",
+ "Branch: master",
+ f"Built: {state['built_at']}",
+ f"Compiler version: {state['compiler_version']}",
+ f"Build: https://github.com/{REPO}/actions/runs/{state['run_id']}",
+ f"Validated CI: https://github.com/{REPO}/actions/runs/{state['ci_run_id']}",
+ "",
+ "This release tracks the latest successfully validated master build.",
+ "",
+ "Download manually below. Nightly is not available through install.sh or install.ps1.",
+ "",
+ ]
+ for name in sorted(state["assets"]):
+ lines.append(
+ f"- [{name}](https://github.com/{REPO}/releases/download/nightly/{name})"
+ )
+ lines += [
+ "",
+ "",
+ ]
+ return "\n".join(lines)
+
+
+def cleanup(github, release_id, state):
+ for asset in github.assets(release_id):
+ if (
+ asset["name"].startswith(("wave-nightly-", "nightly-"))
+ and asset["name"] not in state["assets"]
+ ):
+ try:
+ github.api(f"releases/assets/{asset['id']}", method="DELETE")
+ except RuntimeError as error:
+ # A cleanup failure leaves only surplus assets; retry is safe.
+ print(f"Nightly cleanup deferred: {error}", file=sys.stderr)
+
+
+def promote(github, directory, state):
+ release = github.api("releases/tags/nightly", optional=True)
+ # Draft releases may not be returned by the tag endpoint on first retries.
+ if release is None:
+ candidates = github.api("releases?per_page=100")
+ release = next((r for r in candidates if r["tag_name"] == "nightly"), None)
+ previous = active_state(release)
+ source = state["source_sha"]
+ if previous:
+ verify_remote(github, release["id"], previous)
+ if source != previous["source_sha"] and not ancestor(
+ github, previous["source_sha"], source
+ ):
+ print("Skipping stale or unrelated Nightly generation")
+ return "stale"
+ # Reconcile a tag moved before a cancelled/failed release-body update.
+ set_tag(github, previous["source_sha"])
+ if source == previous["source_sha"]:
+ cleanup(github, release["id"], previous)
+ return "unchanged"
+ if release is None:
+ set_tag(github, source)
+ release = github.api(
+ "releases",
+ method="POST",
+ data={
+ "tag_name": "nightly",
+ "target_commitish": source,
+ "name": "Wave Nightly",
+ "body": "",
+ "draft": True,
+ "prerelease": True,
+ "make_latest": "false",
+ },
+ )
+ existing = {a["name"]: a for a in github.assets(release["id"])}
+ for name, record in state["assets"].items():
+ if name in existing:
+ asset = existing[name]
+ if (
+ asset.get("digest") != "sha256:" + record["sha256"]
+ or asset.get("size") != record["size"]
+ ):
+ raise ValueError(f"conflicting immutable Nightly asset: {name}")
+ else:
+ github.upload(directory / name)
+ verify_remote(github, release["id"], state)
+ # Check ancestry again after a long upload; a rewritten master cannot publish.
+ master = github.api("git/ref/heads/master")["object"]["sha"]
+ if not ancestor(github, source, master):
+ raise ValueError("source left canonical master history during upload")
+ try:
+ set_tag(github, source)
+ github.api(
+ f"releases/{release['id']}",
+ method="PATCH",
+ data={
+ "name": "Wave Nightly",
+ "body": notes(state),
+ "draft": False,
+ "prerelease": True,
+ "make_latest": "false",
+ "target_commitish": source,
+ },
+ )
+ except BaseException:
+ # The API may have committed before the response was lost. Read back
+ # before rollback; otherwise we could corrupt a successful promotion.
+ observed = github.api(f"releases/{release['id']}")
+ if active_state(observed) != state:
+ if previous:
+ set_tag(github, previous["source_sha"])
+ raise
+ observed = github.api(f"releases/{release['id']}")
+ if active_state(observed) != state or observed.get("draft"):
+ raise ValueError("Nightly release promotion could not be verified")
+ verify_remote(github, release["id"], state)
+ if github.api("git/ref/tags/nightly")["object"]["sha"] != source:
+ raise ValueError("Nightly tag differs from the active generation")
+ cleanup(github, release["id"], state)
+ return "published"
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--stage", choices=("gate", "package", "publish"), required=True
+ )
+ parser.add_argument("--ci-run-id", default=os.environ.get("WAVE_NIGHTLY_CI_RUN"))
+ parser.add_argument("--target", default="linux-amd64")
+ parser.add_argument("--provision", action="store_true")
+ parser.add_argument("--report-json", type=Path)
+ parser.add_argument("--publish", action="store_true")
+ parser.add_argument("--directory", type=Path, default=ROOT / "release-assets")
+ options = parser.parse_args(argv)
+ try:
+ if options.publish and options.stage != "publish":
+ raise ValueError("--publish is only valid for the publish stage")
+ if os.environ.get("GITHUB_REPOSITORY") != REPO:
+ raise ValueError("Nightly requires the canonical repository")
+ github = GitHub()
+ identity = eligible(github, options.ci_run_id)
+ if options.stage == "gate":
+ installers_ready()
+ with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
+ for key, value in identity.items():
+ output.write(f"{key}={value}\n")
+ return 0
+ checkout_identity(identity)
+ if options.stage == "package":
+ from tools.ci.common import main as run_plan
+
+ os.environ["RELEASE_VERSION"] = identity["compiler_version"]
+ args = ["--target", options.target]
+ if options.provision:
+ args += ["--provision"]
+ if options.report_json:
+ args += ["--report-json", str(options.report_json)]
+ return run_plan("package", args)
+ if (
+ not options.publish
+ or os.environ.get("GITHUB_ACTIONS") != "true"
+ or os.environ.get("GITHUB_REF") != "refs/heads/master"
+ ):
+ raise ValueError(
+ "Nightly publication requires --publish in the canonical master workflow"
+ )
+ run_id = os.environ["GITHUB_RUN_ID"]
+ own_run = github.api(f"actions/runs/{run_id}")
+ if (
+ own_run.get("path") != ".github/workflows/nightly.yml"
+ or own_run.get("repository", {}).get("full_name") != REPO
+ or own_run.get("event") not in ("workflow_run", "workflow_dispatch")
+ ):
+ raise ValueError("publication must run in the canonical Nightly workflow")
+ installers_ready()
+ revision = json.loads((ROOT / "std/manifest.json").read_text())[
+ "compatibility_revision"
+ ]
+ with tempfile.TemporaryDirectory(prefix="wave-nightly-") as temporary:
+ output = Path(temporary) / "assets"
+ state = stage(
+ options.directory,
+ output,
+ identity,
+ run_id,
+ os.environ["GITHUB_RUN_ATTEMPT"],
+ revision,
+ )
+ print(promote(github, output, state))
+ return 0
+ except (
+ OSError,
+ ValueError,
+ KeyError,
+ RuntimeError,
+ subprocess.SubprocessError,
+ ) as error:
+ print(f"Nightly failed: {error}", file=sys.stderr)
+ return 1
diff --git a/tools/ci/nightly_installers.json b/tools/ci/nightly_installers.json
new file mode 100644
index 00000000..9d5c115c
--- /dev/null
+++ b/tools/ci/nightly_installers.json
@@ -0,0 +1,4 @@
+{
+ "https://wave-lang.dev/install.sh": "a43f2ccf74204f7a4d9b22178929a10ba94f0e5c2e33a86b42e2e2d6ec465d44",
+ "https://wave-lang.dev/install.ps1": "7ac625d01f4aa1a908fe575ab772585c7188e2582bd7394b0004771318ba117c"
+}
diff --git a/tools/ci/procedures.json b/tools/ci/procedures.json
index e3be711a..160a893d 100644
--- a/tools/ci/procedures.json
+++ b/tools/ci/procedures.json
@@ -105,7 +105,8 @@
"tools.test_check_msvc_native",
"tools.test_diagnose_windows_arm64",
"tools.test_runtime_selection",
- "tools.test_ci"
+ "tools.test_ci",
+ "tools.test_nightly"
]
]
},
@@ -228,7 +229,8 @@
"tools.test_test_contracts",
"tools.test_process_tree",
"tools.test_check_msvc_native",
- "tools.test_ci"
+ "tools.test_ci",
+ "tools.test_nightly"
]
]
},
@@ -803,7 +805,8 @@
"tools.test_case_manifest",
"tools.test_test_contracts",
"tools.test_process_tree",
- "tools.test_ci"
+ "tools.test_ci",
+ "tools.test_nightly"
]
]
},
@@ -979,7 +982,8 @@
"tools.test_check_case_sources",
"tools.test_case_execution",
"tools.test_process_tree",
- "tools.test_ci"
+ "tools.test_ci",
+ "tools.test_nightly"
]
]
},
@@ -1486,7 +1490,8 @@
"tools.test_case_manifest",
"tools.test_test_contracts",
"tools.test_release_publish",
- "tools.test_ci"
+ "tools.test_ci",
+ "tools.test_nightly"
]
]
},
diff --git a/tools/ci/release.py b/tools/ci/release.py
index 9dfec320..6fc8dc13 100644
--- a/tools/ci/release.py
+++ b/tools/ci/release.py
@@ -157,4 +157,13 @@ def publish(r, _):
OPERATIONS = {"release_identity": release_identity, "publish": publish}
if __name__ == "__main__":
+ if "--channel" in sys.argv:
+ args = sys.argv[1:]
+ index = args.index("--channel")
+ if args[index : index + 2] != ["--channel", "nightly"]:
+ raise SystemExit("only --channel nightly is supported")
+ del args[index : index + 2]
+ from tools.ci.nightly import main as nightly_main
+
+ sys.exit(nightly_main(args))
sys.exit(main("release"))
diff --git a/tools/run_wasi_smoke.mjs b/tools/run_wasi_smoke.mjs
index 5a171a1d..5774fbe5 100644
--- a/tools/run_wasi_smoke.mjs
+++ b/tools/run_wasi_smoke.mjs
@@ -1,5 +1,5 @@
// Runs a WASI Preview 1 command with the supplied directory preopened as fd 3.
-import { readFile } from "node:fs/promises";
+import { readFileSync } from "node:fs";
import { WASI } from "node:wasi";
const modulePath = process.argv[2];
@@ -16,8 +16,11 @@ const wasi = new WASI({
env: process.env,
preopens: { ".": preopenPath },
});
-const module = await WebAssembly.compile(await readFile(modulePath));
-const instance = await WebAssembly.instantiate(module, wasi.getImportObject());
+// This is a synchronous command runner. Avoid asynchronous module compilation
+// and top-level await so startup and termination do not depend on worker/event
+// loop progress while a preloaded host handle is keeping the process alive.
+const module = new WebAssembly.Module(readFileSync(modulePath));
+const instance = new WebAssembly.Instance(module, wasi.getImportObject());
const leaked = Object.keys(instance.exports).filter((name) => name.startsWith("__wave_"));
if (leaked.length !== 0) {
throw new Error(`private Wave functions leaked into exports: ${leaked.join(", ")}`);
diff --git a/tools/test_ci.py b/tools/test_ci.py
index fd1d793c..3a0ab9ed 100644
--- a/tools/test_ci.py
+++ b/tools/test_ci.py
@@ -84,6 +84,8 @@ def test_wasi_host_propagates_guest_failure_status(self):
("proc_exit_failure", module, 7),
("proc_exit_success", module.replace(bytes.fromhex("410710000b"), bytes.fromhex("410010000b")), 0),
("start_returns", module.replace(bytes.fromhex("0a08010600410710000b"), bytes.fromhex("0a040102000b")), 0),
+ ("start_traps", module.replace(bytes.fromhex("0a08010600410710000b"), bytes.fromhex("0a05010300000b")), 1),
+ ("invalid_module", b"not a WebAssembly module", 1),
]
for name, contents, expected in cases:
with self.subTest(case=name):
diff --git a/tools/test_nightly.py b/tools/test_nightly.py
new file mode 100644
index 00000000..915c8027
--- /dev/null
+++ b/tools/test_nightly.py
@@ -0,0 +1,454 @@
+# SPDX-License-Identifier: MPL-2.0
+"""Nightly tests use an in-memory GitHub; no live release writes."""
+import base64
+import copy
+import hashlib
+import io
+import json
+import os
+from pathlib import Path
+import tempfile
+import unittest
+from unittest.mock import patch
+
+from tools.ci import nightly, package, release, targets
+from tools.check_release_assets import ARCHIVE_TARGETS
+
+A, B, C, OTHER = (c * 40 for c in "abcd")
+
+
+class FakeGitHub:
+ def __init__(self):
+ self.release = None
+ self.tag = None
+ self.files = {}
+ self.calls = []
+ self.fail = None
+ self.upload_failure = False
+ self.corrupt = False
+ self.run = dict(
+ repository={"full_name": nightly.REPO},
+ head_repository={"full_name": nightly.REPO},
+ event="push",
+ head_branch="master",
+ path=".github/workflows/ci.yml",
+ status="completed",
+ conclusion="success",
+ head_sha=A,
+ run_attempt=1,
+ )
+ self.jobs = [dict(status="completed", conclusion="success")]
+
+ def api(self, endpoint, *, method="GET", data=None, optional=False):
+ self.calls.append((method, endpoint, copy.deepcopy(data)))
+ fault = self.fail and self.fail[:2] == (method, endpoint)
+ if fault and self.fail[2] == "before":
+ self.fail = None
+ raise RuntimeError("injected API failure")
+ if endpoint == "git/ref/heads/master":
+ value = {"object": {"sha": C}}
+ elif endpoint.startswith("compare/"):
+ before, after = endpoint.removeprefix("compare/").split("...")
+ ordered = [A, B, C]
+ ahead = (
+ before in ordered
+ and after in ordered
+ and ordered.index(before) < ordered.index(after)
+ )
+ value = {
+ "status": "ahead" if ahead else "behind",
+ "merge_base_commit": {"sha": before},
+ }
+ elif "/jobs?" in endpoint:
+ value = {"jobs": self.jobs}
+ elif endpoint.startswith("actions/runs/"):
+ value = self.run
+ elif endpoint.startswith("contents/Cargo.toml"):
+ value = {
+ "content": base64.b64encode(
+ b'[package]\nversion="0.2.1-pre-beta-dev"'
+ ).decode()
+ }
+ elif endpoint == "git/ref/tags/nightly":
+ value = (
+ {"object": {"sha": self.tag, "type": "commit"}} if self.tag else None
+ )
+ elif endpoint in ("git/refs", "git/refs/tags/nightly"):
+ self.tag = data["sha"]
+ value = {}
+ elif endpoint == "releases/tags/nightly" or (
+ endpoint == "releases/1" and method == "GET"
+ ):
+ value = self.release
+ elif endpoint.startswith("releases?"):
+ value = [self.release] if self.release else []
+ elif endpoint == "releases" and method == "POST":
+ self.release = dict(data, id=1)
+ value = self.release
+ elif endpoint == "releases/1" and method == "PATCH":
+ self.release.update(data)
+ value = self.release
+ elif endpoint.startswith("releases/assets/") and method == "DELETE":
+ name = next(
+ name
+ for name, a in self.files.items()
+ if a["id"] == int(endpoint.split("/")[-1])
+ )
+ del self.files[name]
+ value = None
+ else:
+ raise AssertionError((method, endpoint))
+ if fault:
+ mode = self.fail[2]
+ self.fail = None
+ if mode == "kill":
+ raise SystemExit("simulated process death")
+ raise RuntimeError("response lost after commit")
+ return copy.deepcopy(value)
+
+ def assets(self, release_id):
+ return copy.deepcopy(list(self.files.values()))
+
+ def upload(self, path):
+ self.calls.append(("UPLOAD", path.name, None))
+ if self.upload_failure:
+ raise RuntimeError("upload failed")
+ record = nightly.file_record(path)
+ self.files[path.name] = dict(
+ id=max([a["id"] for a in self.files.values()] + [0]) + 1,
+ name=path.name,
+ size=record["size"],
+ state="uploaded",
+ digest="sha256:" + ("0" * 64 if self.corrupt else record["sha256"]),
+ )
+
+
+class NightlyTests(unittest.TestCase):
+ def setUp(self):
+ self.temp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.temp.cleanup)
+ self.root = Path(self.temp.name)
+ self.github = FakeGitHub()
+ self.version = "0.2.1-pre-beta-dev"
+ self.counter = 0
+
+ def generation(self, source=A):
+ self.counter += 1
+ folder = self.root / str(self.counter)
+ folder.mkdir()
+ for target in ARCHIVE_TARGETS:
+ name = f"wave-v{self.version}-{target}" + (
+ ".zip" if "windows" in target else ".tar.gz"
+ )
+ path = folder / name
+ path.write_bytes((target + source).encode())
+ digest = hashlib.sha256(path.read_bytes()).hexdigest()
+ (folder / (name + ".sha256")).write_text(f"{digest} {name}\n")
+ metadata = dict(
+ schema_version=1,
+ compiler_version=self.version,
+ source_sha=source,
+ std_compatibility_revision=4,
+ target=target,
+ archive=name,
+ sha256=digest,
+ **package.artifact_contract(target),
+ )
+ (folder / (name + ".metadata.json")).write_text(json.dumps(metadata))
+ output = folder / "staged"
+ state = nightly.stage(
+ folder,
+ output,
+ dict(source_sha=source, compiler_version=self.version, ci_run_id=10),
+ 20 + self.counter,
+ 1,
+ 4,
+ )
+ return output, state
+
+ def seed(self):
+ directory, state = self.generation()
+ self.assertEqual(nightly.promote(self.github, directory, state), "published")
+ self.github.calls.clear()
+ return state
+
+ def test_eligibility_checks_repository_event_branch_run_and_ancestry(self):
+ self.assertEqual(nightly.eligible(self.github, 10)["source_sha"], A)
+ for key, value in [
+ ("repository", {"full_name": "fork/Wave"}),
+ ("head_repository", {"full_name": "fork/Wave"}),
+ ("event", "pull_request"),
+ ("head_branch", "feature"),
+ ("path", ".github/workflows/other.yml"),
+ ("status", "in_progress"),
+ ("conclusion", "failure"),
+ ("conclusion", "cancelled"),
+ ("head_sha", OTHER),
+ ]:
+ with self.subTest(key=key, value=value):
+ original = self.github.run[key]
+ self.github.run[key] = value
+ with self.assertRaises(ValueError):
+ nightly.eligible(self.github, 10)
+ self.github.run[key] = original
+ for jobs in [
+ [],
+ [{"status": "completed", "conclusion": "skipped"}],
+ [{"status": "completed", "conclusion": "failure"}],
+ ]:
+ self.github.jobs = jobs
+ with self.assertRaises(ValueError):
+ nightly.eligible(self.github, 10)
+ for value in [None, "../10", "0", "-1"]:
+ with self.assertRaises(ValueError):
+ nightly.eligible(self.github, value)
+
+ def test_all_eight_packages_have_generation_specific_matching_sidecars(self):
+ directory, state = self.generation()
+ archives = list(directory.glob("*.zip")) + list(directory.glob("*.tar.gz"))
+ self.assertEqual(len(archives), 8)
+ self.assertEqual(len(state["assets"]), 26)
+ for path in archives:
+ self.assertIn(A, path.name)
+ metadata = json.loads(
+ (directory / (path.name + ".metadata.json")).read_text()
+ )
+ self.assertEqual(metadata["archive"], path.name)
+ self.assertEqual(metadata["compiler_version"], self.version)
+ self.assertEqual(metadata["source_sha"], A)
+ self.assertIn(path.name, (directory / (path.name + ".sha256")).read_text())
+ self.assertEqual(
+ {t.archive_target for t in targets.TARGETS.values() if t.distribution},
+ set(ARCHIVE_TARGETS),
+ )
+
+ def test_bad_package_sets_fail_before_any_remote_write(self):
+ for bad in ("missing", "checksum", "metadata", "revision"):
+ with self.subTest(bad=bad):
+ directory, _ = self.generation()
+ source = directory.parent
+ archive = next(source.glob("*.tar.gz"))
+ if bad == "missing":
+ archive.unlink()
+ elif bad == "checksum":
+ archive.write_bytes(b"changed")
+ elif bad == "metadata":
+ (source / (archive.name + ".metadata.json")).write_text("{}")
+ with self.assertRaises(ValueError):
+ nightly.stage(
+ source,
+ source / "bad",
+ dict(source_sha=A, compiler_version=self.version),
+ 1,
+ 1,
+ 999 if bad == "revision" else 4,
+ )
+ self.assertEqual(self.github.calls, [])
+
+ def test_first_publication_and_replacement_preserve_previous_until_commit(self):
+ previous = self.seed()
+ directory, state = self.generation(B)
+ self.assertEqual(nightly.promote(self.github, directory, state), "published")
+ self.assertEqual(self.github.tag, B)
+ self.assertEqual(nightly.active_state(self.github.release), state)
+ self.assertFalse(self.github.release["draft"])
+ self.assertTrue(self.github.release["prerelease"])
+ self.assertEqual(self.github.release["make_latest"], "false")
+ calls = self.github.calls
+ commit = next(
+ i for i, c in enumerate(calls) if c[:2] == ("PATCH", "releases/1")
+ )
+ self.assertTrue(
+ all(i > commit for i, c in enumerate(calls) if c[0] == "DELETE")
+ )
+ self.assertTrue(set(previous["assets"]).isdisjoint(self.github.files))
+ self.assertEqual(set(state["assets"]), set(self.github.files))
+
+ def test_failed_or_corrupt_upload_preserves_current_generation_and_tag(self):
+ for mode in ("upload_failure", "corrupt"):
+ with self.subTest(mode=mode):
+ self.github = FakeGitHub()
+ previous = self.seed()
+ directory, state = self.generation(B)
+ setattr(self.github, mode, True)
+ with self.assertRaises((ValueError, RuntimeError)):
+ nightly.promote(self.github, directory, state)
+ self.assertEqual(nightly.active_state(self.github.release), previous)
+ self.assertEqual(self.github.tag, A)
+ self.assertTrue(set(previous["assets"]).issubset(self.github.files))
+ self.assertFalse(any(c[0] == "DELETE" for c in self.github.calls))
+
+ def test_out_of_order_runs_and_duplicate_publication(self):
+ self.seed()
+ directory, state = self.generation(C)
+ nightly.promote(self.github, directory, state)
+ before = copy.deepcopy(self.github.release)
+ for source, expected in [(B, "stale"), (OTHER, "stale"), (C, "unchanged")]:
+ directory, state = self.generation(source)
+ self.assertEqual(nightly.promote(self.github, directory, state), expected)
+ self.assertEqual(self.github.tag, C)
+ self.assertEqual(self.github.release, before)
+
+ def test_body_failure_rolls_back_tag_without_deleting_old_assets(self):
+ previous = self.seed()
+ directory, state = self.generation(B)
+ self.github.fail = ("PATCH", "releases/1", "before")
+ with self.assertRaises(RuntimeError):
+ nightly.promote(self.github, directory, state)
+ self.assertEqual(self.github.tag, A)
+ self.assertEqual(nightly.active_state(self.github.release), previous)
+ self.assertTrue(set(previous["assets"]).issubset(self.github.files))
+ self.assertEqual(nightly.promote(self.github, directory, state), "published")
+
+ def test_lost_promotion_response_is_read_back_not_rolled_back(self):
+ self.seed()
+ directory, state = self.generation(B)
+ self.github.fail = ("PATCH", "releases/1", "after")
+ self.assertEqual(nightly.promote(self.github, directory, state), "published")
+ self.assertEqual(self.github.tag, B)
+
+ def test_retry_recovers_after_process_death_between_tag_and_body(self):
+ previous = self.seed()
+ directory, state = self.generation(B)
+ # Model SIGKILL after the tag request: no Python finally/except runs.
+ for name in state["assets"]:
+ self.github.upload(directory / name)
+ self.github.tag = B
+ self.assertEqual(self.github.tag, B)
+ self.assertEqual(nightly.active_state(self.github.release), previous)
+ self.assertTrue(set(previous["assets"]).issubset(self.github.files))
+ self.assertEqual(nightly.promote(self.github, directory, state), "published")
+
+ def test_interrupted_first_upload_keeps_draft_and_can_retry(self):
+ directory, state = self.generation()
+ self.github.upload_failure = True
+ with self.assertRaises(RuntimeError):
+ nightly.promote(self.github, directory, state)
+ self.assertTrue(self.github.release["draft"])
+ self.github.upload_failure = False
+ self.assertEqual(nightly.promote(self.github, directory, state), "published")
+
+ def test_unmanaged_release_and_conflicting_assets_are_not_overwritten(self):
+ self.seed()
+ self.github.release["body"] = "manually owned release"
+ directory, state = self.generation(B)
+ with self.assertRaises(ValueError):
+ nightly.promote(self.github, directory, state)
+ self.assertEqual(self.github.release["body"], "manually owned release")
+
+ def test_cleanup_failure_is_recoverable_without_rolling_back_new_release(self):
+ self.seed()
+ old_asset = next(iter(self.github.files.values()))
+ directory, state = self.generation(B)
+ self.github.fail = ("DELETE", f"releases/assets/{old_asset['id']}", "before")
+ self.assertEqual(nightly.promote(self.github, directory, state), "published")
+ self.assertEqual(self.github.tag, B)
+ self.assertIn(old_asset["name"], self.github.files)
+ self.assertEqual(nightly.promote(self.github, directory, state), "unchanged")
+ self.assertEqual(set(self.github.files), set(state["assets"]))
+
+ def test_conflicting_immutable_upload_is_never_clobbered(self):
+ previous = self.seed()
+ directory, state = self.generation(B)
+ name = next(iter(state["assets"]))
+ self.github.upload(directory / name)
+ self.github.files[name]["digest"] = "sha256:" + "0" * 64
+ with self.assertRaisesRegex(ValueError, "conflicting immutable"):
+ nightly.promote(self.github, directory, state)
+ self.assertEqual(nightly.active_state(self.github.release), previous)
+ self.assertEqual(self.github.tag, A)
+ self.assertTrue(set(previous["assets"]).issubset(self.github.files))
+
+ def test_publication_authorization_precedes_any_release_write(self):
+ identity = dict(source_sha=A, compiler_version=self.version, ci_run_id=10)
+ base = dict(
+ GITHUB_REPOSITORY=nightly.REPO,
+ GITHUB_ACTIONS="true",
+ GITHUB_REF="refs/heads/master",
+ GITHUB_RUN_ID="30",
+ GITHUB_RUN_ATTEMPT="1",
+ )
+ for changes, flag in [
+ ({}, False),
+ ({"GITHUB_REF": "refs/heads/topic"}, True),
+ ({"GITHUB_ACTIONS": "false"}, True),
+ ]:
+ with self.subTest(changes=changes, flag=flag), patch.dict(
+ os.environ, dict(base, **changes)
+ ), patch.object(nightly, "eligible", return_value=identity), patch.object(
+ nightly, "checkout_identity"
+ ), patch.object(
+ nightly, "promote"
+ ) as promote:
+ args = ["--stage", "publish"] + (["--publish"] if flag else [])
+ self.assertEqual(nightly.main(args), 1)
+ promote.assert_not_called()
+
+ def test_gate_exports_the_validated_sha_and_version(self):
+ identity = dict(source_sha=A, compiler_version=self.version, ci_run_id=10)
+ output = self.root / "outputs"
+ with patch.dict(
+ os.environ,
+ {"GITHUB_REPOSITORY": nightly.REPO, "GITHUB_OUTPUT": str(output)},
+ ), patch.object(nightly, "eligible", return_value=identity), patch.object(
+ nightly, "installers_ready"
+ ), patch.object(
+ nightly, "promote"
+ ) as promote:
+ self.assertEqual(nightly.main(["--stage", "gate", "--ci-run-id", "10"]), 0)
+ self.assertIn("source_sha=" + A, output.read_text())
+ self.assertIn("compiler_version=" + self.version, output.read_text())
+ promote.assert_not_called()
+
+ def test_package_uses_the_existing_target_package_plan(self):
+ identity = dict(source_sha=A, compiler_version=self.version, ci_run_id=10)
+ with patch.dict(os.environ, {"GITHUB_REPOSITORY": nightly.REPO}), patch.object(
+ nightly, "eligible", return_value=identity
+ ), patch.object(nightly, "checkout_identity"), patch(
+ "tools.ci.common.main", return_value=0
+ ) as run:
+ self.assertEqual(
+ nightly.main(
+ ["--stage", "package", "--target", "linux-riscv64", "--provision"]
+ ),
+ 0,
+ )
+ run.assert_called_once_with(
+ "package", ["--target", "linux-riscv64", "--provision"]
+ )
+ self.assertEqual(os.environ["RELEASE_VERSION"], self.version)
+
+ def test_installers_must_match_reviewed_deployed_bytes(self):
+ data = b"fixture"
+ pins = self.root / "pins.json"
+ pins.write_text(
+ json.dumps(
+ {
+ url: hashlib.sha256(data).hexdigest()
+ for url in [
+ "https://wave-lang.dev/install.sh",
+ "https://wave-lang.dev/install.ps1",
+ ]
+ }
+ )
+ )
+ with patch.object(nightly, "INSTALLERS_FILE", pins), patch.object(
+ nightly, "urlopen", side_effect=lambda *a, **k: io.BytesIO(data)
+ ):
+ nightly.installers_ready()
+ with patch.object(nightly, "INSTALLERS_FILE", pins), patch.object(
+ nightly, "urlopen", return_value=io.BytesIO(b"old")
+ ):
+ with self.assertRaises(ValueError):
+ nightly.installers_ready()
+
+ def test_publication_is_opt_in_and_versioned_rules_remain_strict(self):
+ with patch.dict(os.environ, {"GITHUB_REPOSITORY": "fork/Wave"}), patch.object(
+ nightly, "GitHub"
+ ) as github:
+ self.assertEqual(nightly.main(["--stage", "publish"]), 1)
+ github.assert_not_called()
+
+
+if __name__ == "__main__":
+ unittest.main()