diff --git a/build-wasm.log b/build-wasm.log new file mode 100644 index 000000000000..a409358a6be3 --- /dev/null +++ b/build-wasm.log @@ -0,0 +1,5 @@ +> if (!requireNamespace("pak", quietly = TRUE)) install.packages("pak", repos = "https://cloud.r-project.org"); if (!requireNamespace("rwasm", quietly = TRUE)) pak::pak("r-wasm/rwasm"); rwasm::build(".") +Error in if (startsWith(url, "file:")) "internal" else "libcurl" : + missing value where TRUE/FALSE needed +Calls: -> make_remote_tarball -> download.file +Execution halted diff --git a/ci/scripts/r_wasm_test.cjs b/ci/scripts/r_wasm_test.cjs new file mode 100644 index 000000000000..ccc6cb393bad --- /dev/null +++ b/ci/scripts/r_wasm_test.cjs @@ -0,0 +1,165 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +// Smoke-test the arrow R package under webR, then run the testthat suite. +// Called by r_wasm_test.sh. Requires env vars: +// ARROW_WASM_REPO_DIR - local CRAN-like repo with the arrow .tgz +// ARROW_R_TESTS_DIR - path to tests/testthat in the source tree + +const { WebR } = require("webr"); +const http = require("http"); +const fs = require("fs"); +const path = require("path"); + +const repoDir = process.env.ARROW_WASM_REPO_DIR; +if (!repoDir) { + console.error("ERROR: ARROW_WASM_REPO_DIR not set"); + process.exit(1); +} + +const testsDir = process.env.ARROW_R_TESTS_DIR; +if (!testsDir) { + console.error("ERROR: ARROW_R_TESTS_DIR not set"); + process.exit(1); +} + +function listFilesRecursive(dir) { + const results = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...listFilesRecursive(full)); + } else { + results.push(full); + } + } + return results; +} + +async function main() { + // Serve the repo over HTTP (webR can't access the host filesystem directly) + const server = http.createServer((req, res) => { + const filePath = path.join(repoDir, decodeURIComponent(req.url)); + if (!filePath.startsWith(path.resolve(repoDir))) { + res.writeHead(403); + res.end(); + return; + } + fs.readFile(filePath, (err, data) => { + if (err) { + res.writeHead(404); + res.end(); + } else { + res.writeHead(200); + res.end(data); + } + }); + }); + server.listen(8080); + console.log("✓ Repo server on :8080"); + + const webR = new WebR({ RArgs: ["--quiet"], interactive: false }); + await webR.init(); + console.log("✓ webR initialized"); + + // Upload test files to webR VFS (rwasm doesn't include tests in binaries) + const vfsTestDir = "/tmp/arrow-tests"; + await webR.FS.mkdir(vfsTestDir); + const testFiles = listFilesRecursive(testsDir); + const createdDirs = new Set([vfsTestDir]); + for (const file of testFiles) { + const rel = path.relative(testsDir, file); + const vfsPath = path.posix.join(vfsTestDir, rel.split(path.sep).join("/")); + const vfsDir = path.posix.dirname(vfsPath); + if (!createdDirs.has(vfsDir)) { + await webR.evalRVoid(`dir.create("${vfsDir}", recursive=TRUE, showWarnings=FALSE)`); + createdDirs.add(vfsDir); + } + await webR.FS.writeFile(vfsPath, fs.readFileSync(file)); + } + console.log(`✓ Uploaded ${testFiles.length} test files to VFS`); + + // Install arrow from local repo, deps from r-wasm.org + await webR.installPackages(["arrow"], { + repos: ["http://localhost:8080", "https://repo.r-wasm.org"], + quiet: false, + mount: false, + }); + console.log("✓ arrow installed"); + + // Install test deps parsed from DESCRIPTION + const depsList = await webR.evalRString(` + desc <- read.dcf(system.file("DESCRIPTION", package = "arrow"), + fields = c("Imports", "Suggests")) + pkgs <- unlist(strsplit(paste(na.omit(desc[1,]), collapse = ","), ",\\\\s*")) + pkgs <- trimws(sub("\\\\s*\\\\(.*\\\\)", "", pkgs)) + pkgs <- pkgs[pkgs != "" & pkgs != "R"] + pkgs <- pkgs[!pkgs %in% loadedNamespaces()] + paste(pkgs, collapse = "\\n") + `); + const testDeps = depsList.split("\n").filter(Boolean); + console.log(`Installing ${testDeps.length} dependencies from DESCRIPTION...`); + await webR.installPackages(testDeps, { + repos: ["https://repo.r-wasm.org"], + quiet: false, + mount: false, + }); + console.log("✓ test dependencies installed"); + + // Smoke test: package loads, threading disabled, basic operations work + const loadResult = await webR.evalRString(` + library(arrow) + cat("R.version$os =", R.version$os, "\\n") + stopifnot(identical(getOption("arrow.use_threads"), FALSE)) + tab <- arrow::as_arrow_table(data.frame(x = 1:10, y = letters[1:10])) + stopifnot(nrow(tab) == 10L) + cat("Created table with", nrow(tab), "rows\\n") + "PASS" + `); + if (loadResult !== "PASS") { + throw new Error("Smoke test failed"); + } + console.log("✓ Smoke test passed"); + + // Run testthat suite + console.log("Running testthat suite..."); + const testResult = await webR.evalRString(` + library(testthat) + results <- testthat::test_dir( + "${vfsTestDir}", + reporter = "summary", + stop_on_failure = FALSE, + package = "arrow" + ) + df <- as.data.frame(results) + cat(sprintf("Results: %d passed, %d skipped, %d failed, %d errors\\n", + sum(df$passed), sum(df$skipped), sum(df$failed), sum(df$error))) + if (sum(df$failed) > 0 || sum(df$error) > 0) "FAIL" else "PASS" + `); + if (testResult !== "PASS") { + throw new Error("testthat suite failed"); + } + console.log("✓ testthat suite passed"); + + await webR.close(); + server.close(); +} + +main().catch((e) => { + console.error("FAILED:", e); + process.exit(1); +}); diff --git a/ci/scripts/r_wasm_test.sh b/ci/scripts/r_wasm_test.sh new file mode 100755 index 000000000000..d50a15d4e52b --- /dev/null +++ b/ci/scripts/r_wasm_test.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# Test the arrow R package built for WebAssembly. +# +# This script is intended to run inside the ghcr.io/r-universe-org/build-wasm +# Docker container after rwasm::build() has produced a .tgz binary. It: +# 1. Sets up a CRAN-like repo structure from the built .tgz +# 2. Installs the npm webr package (Node.js webR runtime) +# 3. Boots webR, installs arrow from the local repo, and verifies: +# - The package can be installed and loaded +# - Multithreading is disabled (arrow.use_threads == FALSE) +# - The testthat test suite runs +# +# Tests that require threading are automatically skipped via +# skip_if_not(CanRunWithCapturedR()) since CanRunWithCapturedR() returns +# FALSE under Emscripten. +# +# Usage: +# r_wasm_test.sh +# +# Example: +# r_wasm_test.sh /work +# +# The arrow .tgz file(s) should already exist in . + +set -euxo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +arrow_r_dir="${1:-.}" + +# Set up a fake CRAN-like repo so we can install the package +tgz_file=$(ls "${arrow_r_dir}"/arrow_*.tgz 2>/dev/null | head -1) +if [ -z "${tgz_file}" ]; then + echo "ERROR: No arrow_*.tgz found in ${arrow_r_dir}" >&2 + exit 1 +fi +echo "Found Wasm binary: ${tgz_file}" + +repo_dir=$(mktemp -d) +# TODO: Not sure if we need this +# Cover multiple R minor versions in case the npm webr package +# uses a different R version than the Docker image's build R. +for r_ver in 4.4 4.5 4.6; do + contrib_dir="${repo_dir}/bin/emscripten/contrib/${r_ver}" + mkdir -p "${contrib_dir}" + cp "${tgz_file}" "${contrib_dir}/" + # type=mac.binary matches .tgz file extension + R -q -e "tools::write_PACKAGES('${contrib_dir}', type = 'mac.binary')" +done + +echo "Repo structure:" +find "${repo_dir}" -type f + +# Install webr in a temporary node project +work_dir=$(mktemp -d) +cd "${work_dir}" +npm init -y > /dev/null 2>&1 +npm install --silent webr 2>/dev/null + +# Run our test script +ARROW_WASM_REPO_DIR="${repo_dir}" ARROW_R_TESTS_DIR="${arrow_r_dir}/tests/testthat" NODE_PATH="${work_dir}/node_modules" node "${SCRIPT_DIR}/r_wasm_test.cjs" + +# Cleanup temp dirs +rm -rf "${work_dir}" "${repo_dir}" diff --git a/cpp/src/arrow/vendored/datetime/visibility.h b/cpp/src/arrow/vendored/datetime/visibility.h index 780c00d70bd9..492d189a0397 100644 --- a/cpp/src/arrow/vendored/datetime/visibility.h +++ b/cpp/src/arrow/vendored/datetime/visibility.h @@ -21,6 +21,12 @@ # define USE_OS_TZDB 1 #endif +#ifdef __EMSCRIPTEN__ +// Emscripten has no OS timezone database and no curl, so disable both. +# undef USE_OS_TZDB +# define HAS_REMOTE_API 0 +#endif + #if defined(ARROW_STATIC) // intentially empty #elif defined(ARROW_EXPORTING) diff --git a/dev/tasks/r/github.linux.r-wasm.yml b/dev/tasks/r/github.linux.r-wasm.yml index ee38740bb47d..542be91d9e7b 100644 --- a/dev/tasks/r/github.linux.r-wasm.yml +++ b/dev/tasks/r/github.linux.r-wasm.yml @@ -21,7 +21,7 @@ jobs: r-universe-wasm: - name: "R-universe Wasm build" + name: "R-universe Wasm build and test" runs-on: ubuntu-latest timeout-minutes: 60 @@ -56,6 +56,15 @@ jobs: 2>&1 | tee build-wasm.log ' + - name: Smoke-test arrow in webR + shell: bash + run: | + docker run --rm \ + -v "${PWD}/arrow:/arrow" \ + -w /tmp \ + ghcr.io/r-universe-org/build-wasm:latest \ + bash /arrow/ci/scripts/r_wasm_test.sh /arrow/r + - name: List generated artifacts if: always() shell: bash diff --git a/r/R/arrow-package.R b/r/R/arrow-package.R index 2706faee5cb1..179bc1945c30 100644 --- a/r/R/arrow-package.R +++ b/r/R/arrow-package.R @@ -150,6 +150,13 @@ s3_finalizer <- new.env(parent = emptyenv()) # needs the C++ library loaded create_binding_cache() + if (identical(R.version$os, "emscripten")) { + # Disable multithreading on Wasm/Emscripten + options(arrow.use_threads = FALSE) + # No system tzdata on Emscripten; use the tzdb R package + configure_tzdb() + } + if (tolower(Sys.info()[["sysname"]]) == "windows") { # Disable multithreading on Windows # See https://issues.apache.org/jira/browse/ARROW-8379 @@ -188,8 +195,7 @@ configure_tzdb <- function() { error = function(e) { packageStartupMessage( "The tzdb package was available but failed to initialize: ", - e, - "Timezones will not be available to Arrow compute functions." + e ) } ) diff --git a/r/run-wasm-build-and-test.sh b/r/run-wasm-build-and-test.sh new file mode 100644 index 000000000000..d89530b310c8 --- /dev/null +++ b/r/run-wasm-build-and-test.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Build and test the Arrow R package for WebAssembly locally, +# replicating the test-r-wasm crossbow job exactly. +# +# Usage: bash r/run-wasm-build-and-test.sh +# Run from the repo root. + +set -euxo pipefail + +cd "$(git rev-parse --show-toplevel)/r" + +# Step 1: sync C++ source and build R source tarball (done on host, not in Docker) +make sync-cpp +R CMD build --no-build-vignettes . + +# Step 2: build Wasm binary inside the R-universe container +docker run --rm \ + -v "${PWD}:/work" \ + -w /work \ + ghcr.io/r-universe-org/build-wasm:latest \ + bash -lc ' + set -euxo pipefail + R -q -e "if (!requireNamespace(\"pak\", quietly = TRUE)) install.packages(\"pak\", repos = \"https://cloud.r-project.org\"); if (!requireNamespace(\"rwasm\", quietly = TRUE)) pak::pak(\"r-wasm/rwasm\"); rwasm::build(\".\")" \ + 2>&1 | tee build-wasm.log + ' + +# Step 3: run the test suite inside the R-universe container +ARROW_ROOT="$(git rev-parse --show-toplevel)" +docker run --rm \ + -v "${ARROW_ROOT}:/arrow" \ + -w /tmp \ + ghcr.io/r-universe-org/build-wasm:latest \ + bash /arrow/ci/scripts/r_wasm_test.sh /arrow/r diff --git a/r/src/safe-call-into-r-impl.cpp b/r/src/safe-call-into-r-impl.cpp index c2fa1e1eac6b..bb3530cb0021 100644 --- a/r/src/safe-call-into-r-impl.cpp +++ b/r/src/safe-call-into-r-impl.cpp @@ -45,7 +45,16 @@ bool SetEnableSignalStopSource(bool enabled) { } // [[arrow::export]] -bool CanRunWithCapturedR() { return MainRThread::GetInstance().Executor() == nullptr; } +bool CanRunWithCapturedR() { +#ifdef __EMSCRIPTEN__ + // Threading is not supported under Emscripten/WASM. Always take the + // synchronous path to avoid attempting pthread_create which will fail + // with "thread constructor failed: Not supported". + return false; +#else + return MainRThread::GetInstance().Executor() == nullptr; +#endif +} // [[arrow::export]] std::string TestSafeCallIntoR(cpp11::function r_fun_that_returns_a_string, diff --git a/r/tests/testthat/helper-skip.R b/r/tests/testthat/helper-skip.R index 133b03798813..ed2bfae0dbb7 100644 --- a/r/tests/testthat/helper-skip.R +++ b/r/tests/testthat/helper-skip.R @@ -101,6 +101,14 @@ skip_on_linux_devel <- function() { } } +skip_on_emscripten <- function() { + if (force_tests()) { + return() + } + + skip_if(identical(R.version$os, "emscripten"), "Not supported on Emscripten") +} + skip_on_r_older_than <- function(r_version) { if (force_tests()) { return() diff --git a/r/tests/testthat/test-dataset-dplyr.R b/r/tests/testthat/test-dataset-dplyr.R index 09ca0ef155b7..a755ecebbd66 100644 --- a/r/tests/testthat/test-dataset-dplyr.R +++ b/r/tests/testthat/test-dataset-dplyr.R @@ -70,6 +70,7 @@ test_that("filter() with %in%", { }) test_that("filter() on timestamp columns", { + # skip_on_emscripten() # TODO: remove if timezone fix (visibility.h) works skip_if_not_available("re2") ds <- open_dataset(dataset_dir, partitioning = schema(part = uint8())) @@ -119,6 +120,7 @@ test_that("filter() on date32 columns", { ) skip_if_not_available("re2") + # skip_on_emscripten() # TODO: remove if timezone fix (visibility.h) works # Also with timestamp scalar expect_equal( diff --git a/r/tests/testthat/test-dplyr-arrange.R b/r/tests/testthat/test-dplyr-arrange.R index d502a7f040a1..2412bbd2e1e8 100644 --- a/r/tests/testthat/test-dplyr-arrange.R +++ b/r/tests/testthat/test-dplyr-arrange.R @@ -238,6 +238,7 @@ test_that("Can use across() within arrange()", { collect(), example_data ) + # skip_on_emscripten() # TODO: Unrelated to timezone fix; passing a bare function (desc) to across() .fns fails on Emscripten compare_dplyr_binding( .input |> arrange(across(starts_with("d"), desc)) |> diff --git a/r/tests/testthat/test-dplyr-filter.R b/r/tests/testthat/test-dplyr-filter.R index ad69b26be798..fe7c84c80a8d 100644 --- a/r/tests/testthat/test-dplyr-filter.R +++ b/r/tests/testthat/test-dplyr-filter.R @@ -25,7 +25,11 @@ tbl <- example_data tbl$verses <- verses[[1]] # c(" a ", " b ", " c ", ...) increasing padding # nchar = 3 5 7 9 11 13 15 17 19 21 -tbl$padded_strings <- stringr::str_pad(letters[1:10], width = 2 * (1:10) + 1, side = "both") +tbl$padded_strings <- stringr::str_pad( + letters[1:10], + width = 2 * (1:10) + 1, + side = "both" +) tbl$some_negative <- tbl$int * (-1)^(1:nrow(tbl)) # nolint test_that("filter() on is.na()", { @@ -415,6 +419,8 @@ test_that("filter() with namespaced functions", { }) test_that("filter() with across()", { + # skip_on_emscripten() # TODO: Remove if timezone fix works + compare_dplyr_binding( .input |> filter(if_any(ends_with("l"), ~ is.na(.))) |> diff --git a/r/tests/testthat/test-dplyr-query.R b/r/tests/testthat/test-dplyr-query.R index 53864a166337..257e9dabe317 100644 --- a/r/tests/testthat/test-dplyr-query.R +++ b/r/tests/testthat/test-dplyr-query.R @@ -646,6 +646,7 @@ test_that("Scalars in expressions match the type of the field, if possible", { # it merges. # https://github.com/r-hub/rhub-linux-builders/pull/65 skip_if(identical(Sys.timezone(), "/UTC")) + # skip_on_emscripten() # TODO: remove if timezone fix (visibility.h) works expect_output( tab |> diff --git a/r/tools/nixlibs.R b/r/tools/nixlibs.R index ba705e03ad7e..2a8a7821f1c7 100644 --- a/r/tools/nixlibs.R +++ b/r/tools/nixlibs.R @@ -527,6 +527,10 @@ build_libarrow <- function(src_dir, dst_dir) { # CRAN policy says not to use more than 2 cores during checks # If you have more and want to use more, set MAKEFLAGS or NOT_CRAN ncores <- parallel::detectCores() + # detectCores() returns NA in some environments under emscripten, handle it + if (is.na(ncores)) { + ncores <- 1L + } if (!not_cran) { ncores <- min(ncores, 2) }