Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -366,10 +366,12 @@ jobs:
run: |
cargo xtask generate tests preview2

- name: Preview 2 filesystem worker resources
- name: Preview 2 Deno worker resources
timeout-minutes: 10
run: pnpm --filter @bytecodealliance/preview2-shim run test:deno

- name: WASI Preview 2 Conformance
timeout-minutes: 10
run: cargo test deno_

test-wasi-node:
Expand Down
196 changes: 180 additions & 16 deletions crates/xtask/src/generate/preview2_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,69 @@ const DEFAULT_TEST_FILTER: &[&str] = &[];

const DEFAULT_ATTEMPTS: u32 = 5;

/// Convert Wasmtime's current test-program names to the names historically used by
/// this generator. Programs outside the Preview 1/2 conformance suites are skipped.
fn conformance_test_name(name: &str) -> Option<String> {
if let Some(name) = name.strip_prefix("p1_") {
return Some(format!("preview1_{name}"));
}

if let Some(name) = name.strip_prefix("p2_") {
// Before Wasmtime gave every test program a preview-specific prefix, most
// Preview 2 programs had no prefix. Keep those stable names so the existing
// environment and skip policies continue to apply.
let had_preview2_prefix = matches!(
name,
"adapter_badfd" | "file_read_write" | "ip_name_lookup" | "random" | "sleep"
) || name.starts_with("pollable_")
|| name.starts_with("stream_")
|| name.starts_with("tcp_")
|| name.starts_with("tls_")
|| name.starts_with("udp_");

return Some(if had_preview2_prefix {
format!("preview2_{name}")
} else {
name.to_owned()
});
}

name.starts_with("piped_").then(|| name.to_owned())
}

/// Tests that should be ignored
const TEST_IGNORE: &[&str] = &[
// Wasmtime run supports a `wasmtime run --argv0=...` argument to customize the argv0
// which this test assumes is being used. We don't support this feature.
"cli_argv0",
// This reactor exports async echo for Wasmtime's `--invoke` CLI test, not
// wasi:cli/run. The generic `jco run` harness cannot exercise that export.
"cli_invoke_async",
// We don't have interrupts.
"cli_sleep_forever",
// These programs exercise Wasmtime runner limits or require runner-specific
// arguments and configuration that the generic JCO harness does not provide.
"cli_hostcall_fuel",
"cli_http_headers",
"cli_initial_cwd",
"cli_many_resources",
"cli_much_stdout",
"cli_no_tcp",
"cli_no_udp",
"preview1_cli_hostcall_fuel",
"preview1_cli_much_stdout",
// These programs require Wasmtime's read-only preopen fixture.
"file_hardlink_across_perms",
"file_rename_across_perms",
"file_truncation_readonly",
"preview1_file_hardlink_across_perms",
"preview1_file_rename_across_perms",
"preview1_file_truncation_readonly",
// TODO: support the newer HTTP, TLS, and UDP conformance cases.
"http_outbound_request_response_build",
"preview2_tls_sample_application",
"preview2_udp_connect",
"preview2_udp_send_too_much",
// Don't currently support WASI config store.
"config_get",
"cli_serve_config",
Expand All @@ -39,6 +95,22 @@ const TEST_IGNORE: &[&str] = &[
/// can add these anytime!
const KEYWORD_IGNORE: &[&str] = &["nn_", "keyvalue", "runtime_config"];

/// Select programs supported by the generic command harness before emitting
/// either Node or Deno tests. Preview prefixes alone do not imply a CLI command.
fn selected_conformance_test_name(module_name: &str) -> Option<String> {
let test_name = conformance_test_name(module_name)?;
if TEST_IGNORE.contains(&test_name.as_str())
|| KEYWORD_IGNORE
.iter()
.any(|keyword| module_name.contains(keyword))
{
return None;
}
Some(test_name)
}

/// Tests not run under Deno. The socket programs are here as a group: the Deno path through
/// the Preview 2 shim does not deliver socket readiness the way these expect.
const DENO_IGNORE: &[&str] = &[
"api_read_only",
"cli_directory_list",
Expand Down Expand Up @@ -79,6 +151,11 @@ const DENO_IGNORE: &[&str] = &[
"preview2_file_read_write",
"preview2_sleep",
"preview2_tcp_bind",
// Busy-polls a zero-duration timer alongside a socket-readiness pollable, asserting the
// one does not starve the other. Under Deno the readiness never arrives, so the guest
// spins until the CI step's time limit rather than failing. Its siblings above and below
// are skipped for the same reason: Deno sockets are not exercised by this suite.
"preview2_tcp_busy_poll",
"preview2_tcp_connect",
"preview2_tcp_sample_application",
"preview2_tcp_sockopts",
Expand Down Expand Up @@ -184,20 +261,13 @@ pub fn run() -> Result<()> {
}

let file_name = String::from(entry.file_name().to_str().unwrap());
let test_name = String::from(&file_name[0..file_name.len() - 5]);

if KEYWORD_IGNORE
.iter()
.any(|keyword_ignore| test_name.contains(keyword_ignore))
{
continue;
}
let module_name = String::from(&file_name[0..file_name.len() - 5]);

if TEST_IGNORE.contains(&test_name.as_ref()) {
let Some(test_name) = selected_conformance_test_name(&module_name) else {
continue;
}
};

test_names.push(test_name);
test_names.push((module_name, test_name));
}
test_names.sort();

Expand All @@ -208,8 +278,8 @@ pub fn run() -> Result<()> {
if !filtered_tests.is_empty() {
test_names = test_names
.drain(..)
.filter(|test_name| filtered_tests.contains(test_name))
.collect::<Vec<String>>();
.filter(|(_, test_name)| filtered_tests.contains(test_name))
.collect();
}

// Load the adapter
Expand All @@ -226,13 +296,13 @@ pub fn run() -> Result<()> {
let jco_crate_dir = Arc::new(jco_crate_dir);
let jco_script_path = Arc::new(jco_script_path);
let mut handles = Vec::new();
for test_name in test_names {
for (module_name, test_name) in test_names {
let adapter_bytes = Arc::clone(&adapter_bytes);
let all_names = Arc::clone(&all_names);
let jco_crate_dir = Arc::clone(&jco_crate_dir);
let jco_script_path = Arc::clone(&jco_script_path);

let module_path = output_dir.join(format!("{test_name}.wasm"));
let module_path = output_dir.join(format!("{module_name}.wasm"));
let module_bytes = std::fs::read(&module_path)
.with_context(|| format!("failed to read module @ [{}]", module_path.display()))?;

Expand Down Expand Up @@ -314,7 +384,7 @@ pub fn run() -> Result<()> {

// Wait for all handles
for handle in handles {
let _ = handle.join().map_err(|e| anyhow!("{e:#?}"))?;
handle.join().map_err(|e| anyhow!("{e:#?}"))??;
}

// Sort all names
Expand Down Expand Up @@ -779,4 +849,98 @@ mod tests {
));
assert!(!udp_src.contains("run separately to avoid socket contention"));
}

#[test]
fn selects_and_normalizes_wasmtime_conformance_programs() {
assert_eq!(
conformance_test_name("p1_file_write"),
Some("preview1_file_write".to_owned())
);
assert_eq!(
conformance_test_name("p2_cli_env"),
Some("cli_env".to_owned())
);
assert_eq!(
conformance_test_name("p2_tcp_listen"),
Some("preview2_tcp_listen".to_owned())
);
assert_eq!(
conformance_test_name("piped_simple"),
Some("piped_simple".to_owned())
);
assert_eq!(conformance_test_name("async_readiness"), None);
assert_eq!(conformance_test_name("p3_cli"), None);
assert_eq!(conformance_test_name("dwarf_simple"), None);
}

#[test]
fn excludes_async_invocation_fixture_from_command_tests() {
// This fixture exports an async echo function, not wasi:cli/run. Wasmtime's
// corresponding CLI test explicitly invokes echo("hello?").
assert_eq!(selected_conformance_test_name("p2_cli_invoke_async"), None);
assert_eq!(
selected_conformance_test_name("p2_cli_env"),
Some("cli_env".to_owned())
);
assert_eq!(
selected_conformance_test_name("p2_tcp_listen"),
Some("preview2_tcp_listen".to_owned())
);
}

#[test]
fn deno_skips_the_socket_programs_as_a_group() {
// A socket program that Deno cannot satisfy does not fail, it spins: p2_tcp_busy_poll
// polls a zero-duration timer against a socket-readiness pollable a million times per
// connection, so a missed readiness costs the whole CI step rather than one test.
for module_name in [
"p2_tcp_bind",
"p2_tcp_busy_poll",
"p2_tcp_connect",
"p2_tcp_sample_application",
"p2_tcp_sockopts",
"p2_tcp_states",
"p2_tcp_streams",
"p2_udp_bind",
"p2_udp_connect",
"p2_udp_sample_application",
"p2_udp_states",
] {
// Some are skipped everywhere; the rest must at least be skipped under Deno.
if let Some(test_name) = selected_conformance_test_name(module_name) {
assert!(
DENO_IGNORE.contains(&test_name.as_str()),
"{test_name} must be skipped under Deno",
);
}
}

// busy_poll in particular still runs under node, which is where its regression
// coverage lives.
assert_eq!(
selected_conformance_test_name("p2_tcp_busy_poll"),
Some("preview2_tcp_busy_poll".to_owned())
);
}

#[test]
fn selection_preserves_existing_preview_and_runner_policies() {
for name in [
"p2_cli_hostcall_fuel",
"p1_cli_much_stdout",
"p2_cli_serve_keyvalue",
"p3_cli",
"async_readiness",
] {
assert_eq!(selected_conformance_test_name(name), None, "{name}");
}
assert_eq!(
selected_conformance_test_name("p1_file_write"),
Some("preview1_file_write".to_owned())
);
assert_eq!(
selected_conformance_test_name("piped_simple"),
Some("piped_simple".to_owned())
);
}
}
Binary file modified packages/jco/lib/wasi_snapshot_preview1.command.wasm
Binary file not shown.
Binary file modified packages/jco/lib/wasi_snapshot_preview1.reactor.wasm
Binary file not shown.
14 changes: 3 additions & 11 deletions packages/preview2-shim/src/io/worker-socket-tcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ export function socketTcpBindStart(id: number, localAddress, family) {
return;
}
socket.localAddress = ipSocketAddress(
boundAddress.family.toLowerCase() as IpSocketAddress["tag"],
boundAddress.family,
boundAddress.address,
boundAddress.port,
);
Expand Down Expand Up @@ -316,11 +316,7 @@ export function socketTcpGetLocalAddress(id: number) {
const socket = tcpSockets.get(id)!;
const address = socket.tcpSocket?.address();
if (address && typeof address !== "string" && "family" in address) {
return ipSocketAddress(
address.family.toLowerCase() as IpSocketAddress["tag"],
address.address,
address.port,
);
return ipSocketAddress(address.family, address.address, address.port);
}
if (socket.localAddress) {
return socket.localAddress;
Expand All @@ -333,11 +329,7 @@ export function socketTcpGetRemoteAddress(id: number) {
if (!tcpSocket?.remoteFamily || !tcpSocket.remoteAddress || !tcpSocket.remotePort) {
throw "invalid-state";
}
return ipSocketAddress(
tcpSocket.remoteFamily.toLowerCase() as IpSocketAddress["tag"],
tcpSocket.remoteAddress,
tcpSocket.remotePort,
);
return ipSocketAddress(tcpSocket.remoteFamily, tcpSocket.remoteAddress, tcpSocket.remotePort);
}

export function socketTcpShutdown(id: number, _shutdownType) {
Expand Down
7 changes: 3 additions & 4 deletions packages/preview2-shim/src/io/worker-socket-udp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,10 +123,9 @@ function createIncomingDatagramStream(socket: UdpSocketRecord): DatagramStreamRe
udpSocket.off("error", onError);
}
function onMessage(data, rinfo) {
const family = rinfo.family.toLowerCase();
datagramStream.queue?.push({
data,
remoteAddress: ipSocketAddress(family, rinfo.address, rinfo.port),
remoteAddress: ipSocketAddress(rinfo.family, rinfo.address, rinfo.port),
} as any);
if (!pollState.ready) {
pollStateReady(pollState);
Expand Down Expand Up @@ -238,7 +237,7 @@ export function socketUdpGetLocalAddress(id: number): IpSocketAddress {
} catch (err) {
throw convertSocketError(err);
}
return ipSocketAddress(family.toLowerCase(), address, port);
return ipSocketAddress(family, address, port);
}

/**
Expand All @@ -253,7 +252,7 @@ export function socketUdpGetRemoteAddress(id) {
} catch (err) {
throw convertSocketError(err);
}
return ipSocketAddress(family.toLowerCase(), address, port);
return ipSocketAddress(family, address, port);
}

export function socketUdpStream(id, remoteAddress) {
Expand Down
10 changes: 7 additions & 3 deletions packages/preview2-shim/src/io/worker-sockets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ import {
EWOULDBLOCK,
} from "node:constants";
import {
IpAddressFamily,
IpSocketAddress,
Ipv4Address,
Ipv6Address,
Expand Down Expand Up @@ -287,11 +286,13 @@ export function ipv4ToTuple(ipv4: string) {
}

export function ipSocketAddress(
family: IpAddressFamily,
family: string | number,
addr: string,
port: number,
): IpSocketAddress {
if (family === "ipv4") {
// Node uses "IPv4"/"IPv6", while Deno's Node compatibility layer can
// return 4/6. Accept both, as well as the WASI address-family tags.
if (family === "ipv4" || family === "IPv4" || family === 4) {
return {
tag: "ipv4",
val: {
Expand All @@ -300,6 +301,9 @@ export function ipSocketAddress(
},
};
}
if (family !== "ipv6" && family !== "IPv6" && family !== 6) {
throw "invalid-argument";
}
return {
tag: "ipv6",
val: {
Expand Down
8 changes: 8 additions & 0 deletions packages/preview2-shim/test/deno/sockets.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { checkTcpAddresses } from "../fixtures/sockets/address-families.mjs";

for (const family of ["ipv4", "ipv6"]) {
Deno.test(`TCP worker addresses (${family})`, () => checkTcpAddresses(family));
}

// UDP round trips run in the Node suite: Deno 1 does not implement setTTL,
// which the shim needs to finish binding a UDP socket.
Loading
Loading