github.com/wasilibs/wazerox@v0.0.0-20240124024944-4923be63ab5f/imports/wasi_snapshot_preview1/wasi_stdlib_test.go (about) 1 package wasi_snapshot_preview1_test 2 3 import ( 4 "bytes" 5 "context" 6 _ "embed" 7 "io" 8 "net" 9 "net/http" 10 "os" 11 "os/exec" 12 "path" 13 "sort" 14 "strconv" 15 "strings" 16 "testing" 17 gofstest "testing/fstest" 18 "time" 19 20 wazero "github.com/wasilibs/wazerox" 21 "github.com/wasilibs/wazerox/api" 22 experimentalsock "github.com/wasilibs/wazerox/experimental/sock" 23 "github.com/wasilibs/wazerox/imports/wasi_snapshot_preview1" 24 "github.com/wasilibs/wazerox/internal/fsapi" 25 "github.com/wasilibs/wazerox/internal/fstest" 26 internalsys "github.com/wasilibs/wazerox/internal/sys" 27 "github.com/wasilibs/wazerox/internal/testing/require" 28 "github.com/wasilibs/wazerox/sys" 29 ) 30 31 // sleepALittle directly slows down test execution. So, use this sparingly and 32 // only when so where proper signals are unavailable. 33 var sleepALittle = func() { time.Sleep(500 * time.Millisecond) } 34 35 // This file ensures that the behavior we've implemented not only the wasi 36 // spec, but also at least two compilers use of sdks. 37 38 // wasmCargoWasi was compiled from testdata/cargo-wasi/wasi.rs 39 // 40 //go:embed testdata/cargo-wasi/wasi.wasm 41 var wasmCargoWasi []byte 42 43 // wasmGotip is conditionally compiled from testdata/gotip/wasi.go 44 var wasmGotip []byte 45 46 // wasmTinyGo was compiled from testdata/tinygo/wasi.go 47 // 48 //go:embed testdata/tinygo/wasi.wasm 49 var wasmTinyGo []byte 50 51 // wasmZigCc was compiled from testdata/zig-cc/wasi.c 52 // 53 //go:embed testdata/zig-cc/wasi.wasm 54 var wasmZigCc []byte 55 56 // wasmZig was compiled from testdata/zig/wasi.c 57 // 58 //go:embed testdata/zig/wasi.wasm 59 var wasmZig []byte 60 61 func Test_fdReaddir_ls(t *testing.T) { 62 toolchains := map[string][]byte{ 63 "cargo-wasi": wasmCargoWasi, 64 "tinygo": wasmTinyGo, 65 "zig-cc": wasmZigCc, 66 "zig": wasmZig, 67 } 68 if wasmGotip != nil { 69 toolchains["gotip"] = wasmGotip 70 } 71 72 tmpDir := t.TempDir() 73 require.NoError(t, fstest.WriteTestFiles(tmpDir)) 74 75 tons := path.Join(tmpDir, "tons") 76 require.NoError(t, os.Mkdir(tons, 0o0777)) 77 for i := 0; i < direntCountTons; i++ { 78 require.NoError(t, os.WriteFile(path.Join(tons, strconv.Itoa(i)), nil, 0o0666)) 79 } 80 81 for toolchain, bin := range toolchains { 82 toolchain := toolchain 83 bin := bin 84 t.Run(toolchain, func(t *testing.T) { 85 var expectDots int 86 if toolchain == "zig-cc" { 87 expectDots = 1 88 } 89 testFdReaddirLs(t, bin, toolchain, tmpDir, expectDots) 90 }) 91 } 92 } 93 94 const direntCountTons = 8096 95 96 func testFdReaddirLs(t *testing.T, bin []byte, toolchain, rootDir string, expectDots int) { 97 t.Helper() 98 99 moduleConfig := wazero.NewModuleConfig(). 100 WithFSConfig(wazero.NewFSConfig(). 101 WithReadOnlyDirMount(path.Join(rootDir, "dir"), "/")) 102 103 t.Run("empty directory", func(t *testing.T) { 104 console := compileAndRun(t, testCtx, moduleConfig.WithArgs("wasi", "ls", "./a-"), bin) 105 106 requireLsOut(t, nil, expectDots, console) 107 }) 108 109 t.Run("not a directory", func(t *testing.T) { 110 console := compileAndRun(t, testCtx, moduleConfig.WithArgs("wasi", "ls", "-"), bin) 111 112 require.Equal(t, ` 113 ENOTDIR 114 `, "\n"+console) 115 }) 116 117 t.Run("directory with entries", func(t *testing.T) { 118 console := compileAndRun(t, testCtx, moduleConfig.WithArgs("wasi", "ls", "."), bin) 119 requireLsOut(t, []string{ 120 "./-", 121 "./a-", 122 "./ab-", 123 }, expectDots, console) 124 }) 125 126 t.Run("directory with entries - read twice", func(t *testing.T) { 127 if toolchain == "tinygo" { 128 t.Skip("https://github.com/tinygo-org/tinygo/issues/3823") 129 } 130 131 console := compileAndRun(t, testCtx, moduleConfig.WithArgs("wasi", "ls", ".", "repeat"), bin) 132 requireLsOut(t, []string{ 133 "./-", 134 "./a-", 135 "./ab-", 136 "./-", 137 "./a-", 138 "./ab-", 139 }, expectDots*2, console) 140 }) 141 142 t.Run("directory with tons of entries", func(t *testing.T) { 143 moduleConfig = wazero.NewModuleConfig(). 144 WithFSConfig(wazero.NewFSConfig(). 145 WithReadOnlyDirMount(path.Join(rootDir, "tons"), "/")). 146 WithArgs("wasi", "ls", ".") 147 148 console := compileAndRun(t, testCtx, moduleConfig, bin) 149 150 lines := strings.Split(console, "\n") 151 expected := direntCountTons + 1 /* trailing newline */ 152 expected += expectDots * 2 153 require.Equal(t, expected, len(lines)) 154 }) 155 } 156 157 func requireLsOut(t *testing.T, expected []string, expectDots int, console string) { 158 for i := 0; i < expectDots; i++ { 159 expected = append(expected, "./.", "./..") 160 } 161 162 actual := strings.Split(console, "\n") 163 sort.Strings(actual) // os directories are not lexicographic order 164 actual = actual[1:] // trailing newline 165 166 sort.Strings(expected) 167 if len(actual) == 0 { 168 require.Nil(t, expected) 169 } else { 170 require.Equal(t, expected, actual) 171 } 172 } 173 174 func Test_fdReaddir_stat(t *testing.T) { 175 toolchains := map[string][]byte{ 176 "cargo-wasi": wasmCargoWasi, 177 "tinygo": wasmTinyGo, 178 "zig-cc": wasmZigCc, 179 "zig": wasmZig, 180 } 181 if wasmGotip != nil { 182 toolchains["gotip"] = wasmGotip 183 } 184 185 for toolchain, bin := range toolchains { 186 toolchain := toolchain 187 bin := bin 188 t.Run(toolchain, func(t *testing.T) { 189 testFdReaddirStat(t, bin) 190 }) 191 } 192 } 193 194 func testFdReaddirStat(t *testing.T, bin []byte) { 195 moduleConfig := wazero.NewModuleConfig().WithArgs("wasi", "stat") 196 197 console := compileAndRun(t, testCtx, moduleConfig.WithFS(gofstest.MapFS{}), bin) 198 199 // TODO: switch this to a real stat test 200 require.Equal(t, ` 201 stdin isatty: false 202 stdout isatty: false 203 stderr isatty: false 204 / isatty: false 205 `, "\n"+console) 206 } 207 208 func Test_preopen(t *testing.T) { 209 for toolchain, bin := range map[string][]byte{ 210 "zig": wasmZig, 211 } { 212 toolchain := toolchain 213 bin := bin 214 t.Run(toolchain, func(t *testing.T) { 215 testPreopen(t, bin) 216 }) 217 } 218 } 219 220 func testPreopen(t *testing.T, bin []byte) { 221 moduleConfig := wazero.NewModuleConfig().WithArgs("wasi", "preopen") 222 223 console := compileAndRun(t, testCtx, moduleConfig. 224 WithFSConfig(wazero.NewFSConfig(). 225 WithDirMount(".", "/"). 226 WithFSMount(gofstest.MapFS{}, "/tmp")), bin) 227 228 require.Equal(t, ` 229 0: stdin 230 1: stdout 231 2: stderr 232 3: / 233 4: /tmp 234 `, "\n"+console) 235 } 236 237 func compileAndRun(t *testing.T, ctx context.Context, config wazero.ModuleConfig, bin []byte) (console string) { 238 return compileAndRunWithPreStart(t, ctx, config, bin, nil) 239 } 240 241 func compileAndRunWithPreStart(t *testing.T, ctx context.Context, config wazero.ModuleConfig, bin []byte, preStart func(t *testing.T, mod api.Module)) (console string) { 242 // same for console and stderr as sometimes the stack trace is in one or the other. 243 var consoleBuf bytes.Buffer 244 245 r := wazero.NewRuntime(ctx) 246 defer r.Close(ctx) 247 248 _, err := wasi_snapshot_preview1.Instantiate(ctx, r) 249 require.NoError(t, err) 250 251 mod, err := r.InstantiateWithConfig(ctx, bin, config. 252 WithStdout(&consoleBuf). 253 WithStderr(&consoleBuf). 254 WithStartFunctions()) // clear 255 require.NoError(t, err) 256 257 if preStart != nil { 258 preStart(t, mod) 259 } 260 261 _, err = mod.ExportedFunction("_start").Call(ctx) 262 if exitErr, ok := err.(*sys.ExitError); ok { 263 require.Zero(t, exitErr.ExitCode(), consoleBuf.String()) 264 } else { 265 require.NoError(t, err, consoleBuf.String()) 266 } 267 268 console = consoleBuf.String() 269 return 270 } 271 272 // compileAndRunForked executes the test case with the wazero runtime in a separate process, and waits for it to terminate. 273 // 274 // Stdout is captured to a buffer, and stderr is dumped to os.Stderr. 275 // It returns the capture buffer and boolean; the boolean is true if we are running in the outer process. 276 // If it is false, it means we are running in the subprocess; all the verification logic should be handled 277 // when the boolean is true. 278 // 279 // A typical usage pattern will be: 280 // 281 // if buf, hasRun := compileAndRunForked(...); hasRun { 282 // validateContents(buf) 283 // } 284 func compileAndRunForked(t *testing.T, ctx context.Context, config wazero.ModuleConfig, tname string, bin []byte) ([]byte, bool) { 285 var buf bytes.Buffer 286 // We use the technique described in https://go.dev/talks/2014/testing.slide#23 287 // We check if we are running forked by ensuring that a "magic" environment variable is set. 288 if os.Getenv("_TEST_FORKED") != "1" { 289 // If said variable is not set, then we need to exec this same executable, specifying the name of test. 290 // We could use t.Name(), but because t may be arbitrarily nested, it is better to get the name of the test 291 // from a parameter. 292 cmd := exec.Command(os.Args[0], "-test.run", tname) 293 cmd.Stdout = &buf 294 cmd.Stderr = os.Stderr 295 cmd.Env = append(os.Environ(), "_TEST_FORKED=1") 296 err := cmd.Run() 297 if e, ok := err.(*exec.ExitError); ok && !e.Success() { 298 require.NoError(t, e, "The test quit with an error code: %v\n", e) 299 } 300 res := buf.Bytes() 301 // This is a test, so in case of success, it will include the "PASS\n" string: 302 // we remove that from the output, to return a clean stdout. 303 return res[0 : len(res)-len("PASS\n")], true 304 } 305 306 r := wazero.NewRuntime(ctx) 307 defer r.Close(ctx) 308 309 _, err := wasi_snapshot_preview1.Instantiate(ctx, r) 310 require.NoError(t, err) 311 312 mod, err := r.InstantiateWithConfig(ctx, bin, config. 313 WithStartFunctions()) // clear 314 require.NoError(t, err) 315 316 _, err = mod.ExportedFunction("_start").Call(ctx) 317 if exitErr, ok := err.(*sys.ExitError); ok { 318 require.Zero(t, exitErr.ExitCode()) 319 } else { 320 require.NoError(t, err) 321 } 322 return nil, false 323 } 324 325 func Test_Poll(t *testing.T) { 326 // The following test cases replace Stdin with a custom reader. 327 // For more precise coverage, see poll_test.go. 328 329 tests := []struct { 330 name string 331 args []string 332 stdin fsapi.File 333 expectedOutput string 334 expectedTimeout time.Duration 335 }{ 336 { 337 name: "custom reader, data ready, not tty", 338 args: []string{"wasi", "poll"}, 339 stdin: &internalsys.StdinFile{Reader: strings.NewReader("test")}, 340 expectedOutput: "STDIN", 341 expectedTimeout: 0 * time.Millisecond, 342 }, 343 { 344 name: "custom reader, data ready, not tty, .5sec", 345 args: []string{"wasi", "poll", "0", "500"}, 346 stdin: &internalsys.StdinFile{Reader: strings.NewReader("test")}, 347 expectedOutput: "STDIN", 348 expectedTimeout: 0 * time.Millisecond, 349 }, 350 { 351 name: "custom reader, data ready, tty, .5sec", 352 args: []string{"wasi", "poll", "0", "500"}, 353 stdin: &ttyStdinFile{StdinFile: internalsys.StdinFile{Reader: strings.NewReader("test")}}, 354 expectedOutput: "STDIN", 355 expectedTimeout: 0 * time.Millisecond, 356 }, 357 { 358 name: "custom, blocking reader, no data, tty, .5sec", 359 args: []string{"wasi", "poll", "0", "500"}, 360 stdin: &neverReadyTtyStdinFile{StdinFile: internalsys.StdinFile{Reader: newBlockingReader(t)}}, 361 expectedOutput: "NOINPUT", 362 expectedTimeout: 500 * time.Millisecond, // always timeouts 363 }, 364 { 365 name: "eofReader, not tty, .5sec", 366 args: []string{"wasi", "poll", "0", "500"}, 367 stdin: &ttyStdinFile{StdinFile: internalsys.StdinFile{Reader: eofReader{}}}, 368 expectedOutput: "STDIN", 369 expectedTimeout: 0 * time.Millisecond, 370 }, 371 } 372 373 for _, tt := range tests { 374 tc := tt 375 t.Run(tc.name, func(t *testing.T) { 376 start := time.Now() 377 console := compileAndRunWithPreStart(t, testCtx, wazero.NewModuleConfig().WithArgs(tc.args...), wasmZigCc, 378 func(t *testing.T, mod api.Module) { 379 setStdin(t, mod, tc.stdin) 380 }) 381 elapsed := time.Since(start) 382 require.True(t, elapsed >= tc.expectedTimeout) 383 require.Equal(t, tc.expectedOutput+"\n", console) 384 }) 385 } 386 } 387 388 // eofReader is safer than reading from os.DevNull as it can never overrun operating system file descriptors. 389 type eofReader struct{} 390 391 // Read implements io.Reader 392 // Note: This doesn't use a pointer reference as it has no state and an empty struct doesn't allocate. 393 func (eofReader) Read([]byte) (int, error) { 394 return 0, io.EOF 395 } 396 397 func Test_Sleep(t *testing.T) { 398 moduleConfig := wazero.NewModuleConfig().WithArgs("wasi", "sleepmillis", "100").WithSysNanosleep() 399 start := time.Now() 400 console := compileAndRun(t, testCtx, moduleConfig, wasmZigCc) 401 require.True(t, time.Since(start) >= 100*time.Millisecond) 402 require.Equal(t, "OK\n", console) 403 } 404 405 func Test_Open(t *testing.T) { 406 for toolchain, bin := range map[string][]byte{ 407 "zig-cc": wasmZigCc, 408 } { 409 toolchain := toolchain 410 bin := bin 411 t.Run(toolchain, func(t *testing.T) { 412 testOpenReadOnly(t, bin) 413 testOpenWriteOnly(t, bin) 414 }) 415 } 416 } 417 418 func testOpenReadOnly(t *testing.T, bin []byte) { 419 testOpen(t, "rdonly", bin) 420 } 421 422 func testOpenWriteOnly(t *testing.T, bin []byte) { 423 testOpen(t, "wronly", bin) 424 } 425 426 func testOpen(t *testing.T, cmd string, bin []byte) { 427 t.Run(cmd, func(t *testing.T) { 428 moduleConfig := wazero.NewModuleConfig(). 429 WithArgs("wasi", "open-"+cmd). 430 WithFSConfig(wazero.NewFSConfig().WithDirMount(t.TempDir(), "/")) 431 432 console := compileAndRun(t, testCtx, moduleConfig, bin) 433 require.Equal(t, "OK", strings.TrimSpace(console)) 434 }) 435 } 436 437 func Test_Sock(t *testing.T) { 438 toolchains := map[string][]byte{ 439 "cargo-wasi": wasmCargoWasi, 440 "zig-cc": wasmZigCc, 441 } 442 if wasmGotip != nil { 443 toolchains["gotip"] = wasmGotip 444 } 445 446 for toolchain, bin := range toolchains { 447 toolchain := toolchain 448 bin := bin 449 t.Run(toolchain, func(t *testing.T) { 450 testSock(t, bin) 451 }) 452 } 453 } 454 455 func testSock(t *testing.T, bin []byte) { 456 sockCfg := experimentalsock.NewConfig().WithTCPListener("127.0.0.1", 0) 457 ctx := experimentalsock.WithConfig(testCtx, sockCfg) 458 moduleConfig := wazero.NewModuleConfig().WithArgs("wasi", "sock") 459 tcpAddrCh := make(chan *net.TCPAddr, 1) 460 ch := make(chan string, 1) 461 go func() { 462 ch <- compileAndRunWithPreStart(t, ctx, moduleConfig, bin, func(t *testing.T, mod api.Module) { 463 tcpAddrCh <- requireTCPListenerAddr(t, mod) 464 }) 465 }() 466 tcpAddr := <-tcpAddrCh 467 468 // Give a little time for _start to complete 469 sleepALittle() 470 471 // Now dial to the initial address, which should be now held by wazero. 472 conn, err := net.Dial("tcp", tcpAddr.String()) 473 require.NoError(t, err) 474 defer conn.Close() 475 476 n, err := conn.Write([]byte("wazero")) 477 console := <-ch 478 require.NotEqual(t, 0, n) 479 require.NoError(t, err) 480 // Nonblocking connections may contain error logging, we ignore those. 481 require.Equal(t, "wazero\n", console[len(console)-7:]) 482 } 483 484 func Test_HTTP(t *testing.T) { 485 toolchains := map[string][]byte{} 486 if wasmGotip != nil { 487 toolchains["gotip"] = wasmGotip 488 } 489 490 for toolchain, bin := range toolchains { 491 toolchain := toolchain 492 bin := bin 493 t.Run(toolchain, func(t *testing.T) { 494 testHTTP(t, bin) 495 }) 496 } 497 } 498 499 func testHTTP(t *testing.T, bin []byte) { 500 sockCfg := experimentalsock.NewConfig().WithTCPListener("127.0.0.1", 0) 501 ctx := experimentalsock.WithConfig(testCtx, sockCfg) 502 503 moduleConfig := wazero.NewModuleConfig(). 504 WithSysWalltime().WithSysNanotime(). // HTTP middleware uses both clocks 505 WithArgs("wasi", "http") 506 tcpAddrCh := make(chan *net.TCPAddr, 1) 507 ch := make(chan string, 1) 508 go func() { 509 ch <- compileAndRunWithPreStart(t, ctx, moduleConfig, bin, func(t *testing.T, mod api.Module) { 510 tcpAddrCh <- requireTCPListenerAddr(t, mod) 511 }) 512 }() 513 tcpAddr := <-tcpAddrCh 514 515 // Give a little time for _start to complete 516 sleepALittle() 517 518 // Now, send a POST to the address which we had pre-opened. 519 body := bytes.NewReader([]byte("wazero")) 520 req, err := http.NewRequest(http.MethodPost, "http://"+tcpAddr.String(), body) 521 require.NoError(t, err) 522 523 resp, err := http.DefaultClient.Do(req) 524 require.NoError(t, err) 525 defer resp.Body.Close() 526 527 require.Equal(t, 200, resp.StatusCode) 528 b, err := io.ReadAll(resp.Body) 529 require.NoError(t, err) 530 require.Equal(t, "wazero\n", string(b)) 531 532 console := <-ch 533 require.Equal(t, "", console) 534 } 535 536 func Test_Stdin(t *testing.T) { 537 toolchains := map[string][]byte{} 538 if wasmGotip != nil { 539 toolchains["gotip"] = wasmGotip 540 } 541 542 for toolchain, bin := range toolchains { 543 toolchain := toolchain 544 bin := bin 545 t.Run(toolchain, func(t *testing.T) { 546 testStdin(t, bin) 547 }) 548 } 549 } 550 551 func testStdin(t *testing.T, bin []byte) { 552 stdinReader, stdinWriter, err := os.Pipe() 553 require.NoError(t, err) 554 stdoutReader, stdoutWriter, err := os.Pipe() 555 require.NoError(t, err) 556 defer func() { 557 stdinReader.Close() 558 stdinWriter.Close() 559 stdoutReader.Close() 560 stdoutReader.Close() 561 }() 562 require.NoError(t, err) 563 moduleConfig := wazero.NewModuleConfig(). 564 WithSysNanotime(). // poll_oneoff requires nanotime. 565 WithArgs("wasi", "stdin"). 566 WithStdin(stdinReader). 567 WithStdout(stdoutWriter) 568 ch := make(chan struct{}, 1) 569 go func() { 570 defer close(ch) 571 572 r := wazero.NewRuntime(testCtx) 573 defer r.Close(testCtx) 574 _, err := wasi_snapshot_preview1.Instantiate(testCtx, r) 575 require.NoError(t, err) 576 _, err = r.InstantiateWithConfig(testCtx, bin, moduleConfig) 577 require.NoError(t, err) 578 }() 579 580 time.Sleep(1 * time.Second) 581 buf := make([]byte, 21) 582 _, _ = stdoutReader.Read(buf) 583 require.Equal(t, "waiting for stdin...\n", string(buf)) 584 _, _ = stdinWriter.WriteString("foo") 585 _ = stdinWriter.Close() 586 buf = make([]byte, 3) 587 _, _ = stdoutReader.Read(buf) 588 require.Equal(t, "foo", string(buf)) 589 <-ch 590 } 591 592 func Test_LargeStdout(t *testing.T) { 593 toolchains := map[string][]byte{} 594 if wasmGotip != nil { 595 toolchains["gotip"] = wasmGotip 596 } 597 598 for toolchain, bin := range toolchains { 599 toolchain := toolchain 600 bin := bin 601 name := t.Name() 602 t.Run(toolchain, func(t *testing.T) { 603 testLargeStdout(t, name, bin) 604 }) 605 } 606 } 607 608 func testLargeStdout(t *testing.T, tname string, bin []byte) { 609 // This test dumps a large Go source file to stdout. The generated result 610 // should be valid code, otherwise it means that stdout is corrupted. 611 // 612 // The error conditions are more easily reproduced by executing in a subprocess 613 // and capturing its stdout. 614 if buf, hasRun := compileAndRunForked(t, testCtx, wazero.NewModuleConfig(). 615 WithArgs("wasi", "largestdout"). 616 WithStdout(os.Stdout), tname, bin); hasRun { 617 618 tempDir := t.TempDir() 619 temp, err := os.Create(joinPath(tempDir, "out.go")) 620 require.NoError(t, err) 621 defer temp.Close() 622 623 require.NoError(t, err) 624 _, _ = temp.Write(buf) 625 _ = temp.Close() 626 627 gotipBin, err := findGotipBin() 628 require.NoError(t, err) 629 630 cmd := exec.CommandContext(testCtx, gotipBin, "build", "-o", 631 joinPath(tempDir, "outbin"), temp.Name()) 632 require.NoError(t, err) 633 output, err := cmd.CombinedOutput() 634 require.NoError(t, err, string(output)) 635 } 636 }