diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 25a82fffc..43b61b310 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -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: diff --git a/crates/xtask/src/generate/preview2_tests.rs b/crates/xtask/src/generate/preview2_tests.rs index 5eae4676d..65e1236d7 100644 --- a/crates/xtask/src/generate/preview2_tests.rs +++ b/crates/xtask/src/generate/preview2_tests.rs @@ -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 { + 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", @@ -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 { + 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", @@ -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", @@ -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(); @@ -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::>(); + .filter(|(_, test_name)| filtered_tests.contains(test_name)) + .collect(); } // Load the adapter @@ -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()))?; @@ -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 @@ -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()) + ); + } } diff --git a/packages/jco/lib/wasi_snapshot_preview1.command.wasm b/packages/jco/lib/wasi_snapshot_preview1.command.wasm index b3cef88ff..497cc2970 100644 Binary files a/packages/jco/lib/wasi_snapshot_preview1.command.wasm and b/packages/jco/lib/wasi_snapshot_preview1.command.wasm differ diff --git a/packages/jco/lib/wasi_snapshot_preview1.reactor.wasm b/packages/jco/lib/wasi_snapshot_preview1.reactor.wasm index 9726a4fe3..db9831c27 100644 Binary files a/packages/jco/lib/wasi_snapshot_preview1.reactor.wasm and b/packages/jco/lib/wasi_snapshot_preview1.reactor.wasm differ diff --git a/packages/preview2-shim/src/io/worker-socket-tcp.ts b/packages/preview2-shim/src/io/worker-socket-tcp.ts index 76a9c42af..0e613444e 100644 --- a/packages/preview2-shim/src/io/worker-socket-tcp.ts +++ b/packages/preview2-shim/src/io/worker-socket-tcp.ts @@ -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, ); @@ -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; @@ -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) { diff --git a/packages/preview2-shim/src/io/worker-socket-udp.ts b/packages/preview2-shim/src/io/worker-socket-udp.ts index 7d742d015..f884b9982 100644 --- a/packages/preview2-shim/src/io/worker-socket-udp.ts +++ b/packages/preview2-shim/src/io/worker-socket-udp.ts @@ -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); @@ -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); } /** @@ -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) { diff --git a/packages/preview2-shim/src/io/worker-sockets.ts b/packages/preview2-shim/src/io/worker-sockets.ts index ee0dc3cc2..5d0f6361a 100644 --- a/packages/preview2-shim/src/io/worker-sockets.ts +++ b/packages/preview2-shim/src/io/worker-sockets.ts @@ -33,7 +33,6 @@ import { EWOULDBLOCK, } from "node:constants"; import { - IpAddressFamily, IpSocketAddress, Ipv4Address, Ipv6Address, @@ -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: { @@ -300,6 +301,9 @@ export function ipSocketAddress( }, }; } + if (family !== "ipv6" && family !== "IPv6" && family !== 6) { + throw "invalid-argument"; + } return { tag: "ipv6", val: { diff --git a/packages/preview2-shim/test/deno/sockets.mjs b/packages/preview2-shim/test/deno/sockets.mjs new file mode 100644 index 000000000..e9dfb85db --- /dev/null +++ b/packages/preview2-shim/test/deno/sockets.mjs @@ -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. diff --git a/packages/preview2-shim/test/fixtures/sockets/address-families.mjs b/packages/preview2-shim/test/fixtures/sockets/address-families.mjs new file mode 100644 index 000000000..2668b9a49 --- /dev/null +++ b/packages/preview2-shim/test/fixtures/sockets/address-families.mjs @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; + +import { instanceNetwork, tcpCreateSocket, udpCreateSocket } from "../../../dist/nodejs/sockets.js"; + +const symbolDispose = Symbol.dispose || Symbol.for("dispose"); + +function loopback(family) { + return family === "ipv4" + ? { tag: family, val: { address: [127, 0, 0, 1], port: 0 } } + : { + tag: family, + val: { address: [0, 0, 0, 0, 0, 0, 0, 1], port: 0, flowInfo: 0, scopeId: 0 }, + }; +} + +// Run unchanged under Node and Deno: all socket work happens in the shim's +// I/O worker, including Server.address(), Socket.address(), and UDP messages. +export function checkTcpAddresses(family) { + const resources = []; + const own = (resource) => { + resources.push(resource); + return resource; + }; + try { + const network = instanceNetwork.instanceNetwork(); + const listener = own(tcpCreateSocket.createTcpSocket(family)); + const listenerPoll = own(listener.subscribe()); + listener.startBind(network, loopback(family)); + listenerPoll.block(); + listener.finishBind(); + const bound = listener.localAddress(); + assert.equal(bound.tag, family); + assert.deepEqual(bound.val.address, loopback(family).val.address); + assert.ok(bound.val.port > 0); + listener.startListen(); + listenerPoll.block(); + listener.finishListen(); + + const client = own(tcpCreateSocket.createTcpSocket(family)); + const clientPoll = own(client.subscribe()); + client.startConnect(network, bound); + clientPoll.block(); + client.finishConnect().forEach(own); + listenerPoll.block(); + const [accepted, input, output] = listener.accept(); + [accepted, input, output].forEach(own); + + assert.deepEqual(client.remoteAddress(), bound); + assert.deepEqual(accepted.localAddress(), bound); + assert.deepEqual(accepted.remoteAddress(), client.localAddress()); + assert.equal(client.localAddress().tag, family); + } finally { + for (const resource of resources.reverse()) { + resource[symbolDispose](); + } + } +} + +export function checkUdpAddresses(family) { + const resources = []; + const own = (resource) => { + resources.push(resource); + return resource; + }; + try { + const network = instanceNetwork.instanceNetwork(); + const bind = () => { + const socket = own(udpCreateSocket.createUdpSocket(family)); + const pollable = own(socket.subscribe()); + socket.startBind(network, loopback(family)); + pollable.block(); + socket.finishBind(); + const address = socket.localAddress(); + assert.equal(address.tag, family); + assert.deepEqual(address.val.address, loopback(family).val.address); + assert.ok(address.val.port > 0); + return socket; + }; + const receiver = bind(); + const sender = bind(); + const [incoming, unusedOutput] = receiver.stream(undefined); + [incoming, unusedOutput].forEach(own); + const [unusedInput, outgoing] = sender.stream(receiver.localAddress()); + [unusedInput, outgoing].forEach(own); + assert.deepEqual(sender.remoteAddress(), receiver.localAddress()); + + assert.ok(outgoing.checkSend() > 0n); + const data = new TextEncoder().encode("loopback"); + assert.equal(outgoing.send([{ data, remoteAddress: undefined }]), 1n); + own(incoming.subscribe()).block(); + const received = incoming.receive(1n); + assert.equal(received.length, 1); + assert.deepEqual(received[0].data, data); + assert.deepEqual(received[0].remoteAddress, sender.localAddress()); + } finally { + for (const resource of resources.reverse()) { + resource[symbolDispose](); + } + } +} diff --git a/packages/preview2-shim/test/socket-addresses.ts b/packages/preview2-shim/test/socket-addresses.ts new file mode 100644 index 000000000..5636b56e5 --- /dev/null +++ b/packages/preview2-shim/test/socket-addresses.ts @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; + +import { suite, test } from "vitest"; + +import { ipSocketAddress } from "../src/io/worker-sockets.js"; +import { checkTcpAddresses, checkUdpAddresses } from "./fixtures/sockets/address-families.mjs"; + +suite("socket address families", () => { + test.each(["ipv4", "IPv4", 4])("normalizes IPv4 family %s", (family) => { + assert.deepStrictEqual(ipSocketAddress(family, "127.0.0.1", 1234), { + tag: "ipv4", + val: { address: [127, 0, 0, 1], port: 1234 }, + }); + }); + + test.each(["ipv6", "IPv6", 6])("normalizes IPv6 family %s", (family) => { + assert.deepStrictEqual(ipSocketAddress(family, "::1", 1234), { + tag: "ipv6", + val: { address: [0, 0, 0, 0, 0, 0, 0, 1], port: 1234, flowInfo: 0, scopeId: 0 }, + }); + }); + + test.each([0, 5, "unix", ""])("rejects unknown family %s", (family) => { + assert.throws( + () => ipSocketAddress(family, "127.0.0.1", 1234), + (e) => e === "invalid-argument", + ); + }); + + test.each(["ipv4", "ipv6"])("TCP worker addresses (%s)", checkTcpAddresses); + test.each(["ipv4", "ipv6"])("UDP worker addresses (%s)", checkUdpAddresses); +}); diff --git a/submodules/wasmtime b/submodules/wasmtime index 4b6e96151..90fed3c6a 160000 --- a/submodules/wasmtime +++ b/submodules/wasmtime @@ -1 +1 @@ -Subproject commit 4b6e96151f53705fc8c245e5d8efef31b0637eed +Subproject commit 90fed3c6adf53f112c4dea56851728557bb73799