Marge Bot pushed to branch master at Glasgow Haskell Compiler / GHC
Commits:
-
f6961b02
by Cheng Shao at 2025-11-01T00:08:01+01:00
-
0c9032a0
by Cheng Shao at 2025-11-01T00:08:01+01:00
-
ec1b40bd
by Cheng Shao at 2025-11-01T00:08:01+01:00
-
8f3e481f
by Cheng Shao at 2025-11-01T00:08:01+01:00
-
af40606a
by Cheng Shao at 2025-11-01T00:08:04+01:00
10 changed files:
- + testsuite/tests/ghc-api-browser/README.md
- + testsuite/tests/ghc-api-browser/all.T
- + testsuite/tests/ghc-api-browser/index.html
- + testsuite/tests/ghc-api-browser/playground001.hs
- + testsuite/tests/ghc-api-browser/playground001.js
- + testsuite/tests/ghc-api-browser/playground001.sh
- testsuite/tests/ghci-wasm/T26431.stdout โ testsuite/tests/ghc-api-browser/playground001.stdout
- โ testsuite/tests/ghci-wasm/T26431.hs
- testsuite/tests/ghci-wasm/all.T
- utils/jsffi/dyld.mjs
Changes:
| 1 | +# The Haskell playground browser test
|
|
| 2 | + |
|
| 3 | +This directory contains the `playground001` test, which builds a fully
|
|
| 4 | +client side Haskell playground in the browser, then runs a
|
|
| 5 | +puppeteer-based test to actually interpret a Haskell program in a
|
|
| 6 | +headless browser.
|
|
| 7 | + |
|
| 8 | +## Headless testing
|
|
| 9 | + |
|
| 10 | +`playground001` is tested in GHC CI. To test it locally, first ensure
|
|
| 11 | +you've set up the latest
|
|
| 12 | +[`ghc-wasm-meta`](https://gitlab.haskell.org/haskell-wasm/ghc-wasm-meta)
|
|
| 13 | +toolchain and sourced the `~/.ghc-wasm/env` script, so the right
|
|
| 14 | +`node` with the right pre-installed libraries are used. Additionally,
|
|
| 15 | +you need to install latest Firefox and:
|
|
| 16 | + |
|
| 17 | +```sh
|
|
| 18 | +export FIREFOX_LAUNCH_OPTS='{"browser":"firefox","executablePath":"/usr/bin/firefox"}'`
|
|
| 19 | +```
|
|
| 20 | + |
|
| 21 | +Or on macOS:
|
|
| 22 | + |
|
| 23 | +```sh
|
|
| 24 | +export FIREFOX_LAUNCH_OPTS='{"browser":"firefox","executablePath":"/Applications/Firefox.app/Contents/MacOS/firefox"}'
|
|
| 25 | +```
|
|
| 26 | + |
|
| 27 | +Without `FIREFOX_LAUNCH_OPTS`, `playground001` is skipped.
|
|
| 28 | + |
|
| 29 | +It's possible to test against Chrome as well, the
|
|
| 30 | +[`playground001.js`](./playground001.js) test driver doesn't assume
|
|
| 31 | +anything Firefox-specific, it just takes the
|
|
| 32 | +[`puppeteer.launch`](https://pptr.dev/api/puppeteer.puppeteernode.launch)
|
|
| 33 | +options as JSON passed via command line.
|
|
| 34 | + |
|
| 35 | +`playground001` works on latest versions of Firefox/Chrome/Safari.
|
|
| 36 | + |
|
| 37 | +## Manual testing
|
|
| 38 | + |
|
| 39 | +The simplest way to build the playground manually and run it in a
|
|
| 40 | +browser tab is to test it once with `--only=playground001
|
|
| 41 | +--keep-test-files` passed to Hadrian, then you can find the temporary
|
|
| 42 | +directory containing [`index.html`](./index.html), `rootfs.tar.zst`
|
|
| 43 | +etc, then fire up a dev web server and load it.
|
|
| 44 | + |
|
| 45 | +Additionally, you can build the playground in tree without invoking
|
|
| 46 | +the GHC testsuite. Just build GHC with the wasm target first, then
|
|
| 47 | +copy `utils/jsffi/*.mjs` here and run
|
|
| 48 | +[`./playground001.sh`](./playground001.sh) script. You need to set
|
|
| 49 | +`TEST_CC` to the path of `wasm32-wasi-clang` and `TEST_HC` to the path
|
|
| 50 | +of `wasm32-wasi-ghc`, that's it.
|
|
| 51 | + |
|
| 52 | +## Customized Haskell playground
|
|
| 53 | + |
|
| 54 | +You may want to build a customized Haskell playground that uses GHC
|
|
| 55 | +API to interpret Haskell code with custom packages, here are some tips
|
|
| 56 | +to get started:
|
|
| 57 | + |
|
| 58 | +- Read the code in this directory and figure out how `playground001`
|
|
| 59 | + itself works.
|
|
| 60 | +- [`./playground001.sh`](./playground001.sh) can be used as a basis to
|
|
| 61 | + write your own build/test script.
|
|
| 62 | + |
|
| 63 | +You don't need to read the full `dyld.mjs` script. The user-facing
|
|
| 64 | +things that are relevant to the playground use case are:
|
|
| 65 | + |
|
| 66 | +- `export class DyLDBrowserHost`: it is the `rpc` object required when
|
|
| 67 | + calling `main`. You need to pass `stdout`/`stderr` callbacks to
|
|
| 68 | + write each line of stdout/stderr, as well as a `rootfs` object that
|
|
| 69 | + represents an in-memory vfs containing the shared libraries to load.
|
|
| 70 | +- `export async function main`: it eventually returns a `DyLD` object
|
|
| 71 | + that can be used like `await
|
|
| 72 | + dyld.exportFuncs.myExportedHaskellFunc(js_foo, js_bar)` to invoke
|
|
| 73 | + your exported Haskell function.
|
|
| 74 | + |
|
| 75 | +Check the source code of [`index.html`](./index.html) and cross
|
|
| 76 | +reference [`playground001.hs`](./playground001.hs) for the example of
|
|
| 77 | +how they are used.
|
|
| 78 | + |
|
| 79 | +The `rootfs` object is a
|
|
| 80 | +[`PreopenDirectory`](https://github.com/haskell-wasm/browser_wasi_shim/blob/master/src/fs_mem.ts)
|
|
| 81 | +object in the
|
|
| 82 | +[`browser_wasi_shim`](https://github.com/haskell-wasm/browser_wasi_shim)
|
|
| 83 | +library. The Haskell playground needs a complex vfs containing many
|
|
| 84 | +files (shared libraries, interface files, package databases, etc), so
|
|
| 85 | +to speed things up, the whole vfs is compressed into a
|
|
| 86 | +`rootfs.tar.zst` archive, then that archive is extracted using
|
|
| 87 | +[`bsdtar-wasm`](https://github.com/haskell-wasm/bsdtar-wasm).
|
|
| 88 | + |
|
| 89 | +You don't need to read the source code of `browser_wasi_shim`; you can
|
|
| 90 | +simply paste and adapt the relevant code snippet in
|
|
| 91 | +[`index.html`](./index.html) to create the right `rootfs` object from
|
|
| 92 | +a tarball.
|
|
| 93 | + |
|
| 94 | +The main concern is what do you need to pack into `rootfs.tar.zst`.
|
|
| 95 | +For `playground001`, it contains:
|
|
| 96 | + |
|
| 97 | +- `/tmp/clib`: the C/C++ shared libraries
|
|
| 98 | +- `/tmp/hslib/lib`: the GHC libdir
|
|
| 99 | +- `/tmp/libplayground001.so`: the main shared library to start loading
|
|
| 100 | + that exports `myMain`
|
|
| 101 | + |
|
| 102 | +You can read [`./playground001.sh`](./playground001.sh) to figure out
|
|
| 103 | +the details of how I prepare `rootfs.tar.zst` and trim unneeded files
|
|
| 104 | +to minimize the tarball size.
|
|
| 105 | + |
|
| 106 | +There are multiple possible ways to install third-party packages in
|
|
| 107 | +the playground:
|
|
| 108 | + |
|
| 109 | +- Start from a `wasm32-wasi-ghc` installation, use `wasm32-wasi-cabal
|
|
| 110 | + v1-install --global` to install everything to the global package
|
|
| 111 | + database. In theory this is the simplest way, though I haven't tried
|
|
| 112 | + it myself and it's unclear to what extent do `v1` commands work
|
|
| 113 | + these days.
|
|
| 114 | +- Use default nix-style installation, then package the cabal store and
|
|
| 115 | + `dist-newstyle` directories into `rootfs.tar.zst`, and pass the
|
|
| 116 | + right package database flags when calling GHC API.
|
|
| 117 | + |
|
| 118 | +Note that cabal built packages are not relocatable! So things will
|
|
| 119 | +break if you build them at a host location and then package into a
|
|
| 120 | +different absolute path into the rootfs, keep this in mind.
|
|
| 121 | + |
|
| 122 | +If you have any difficulties, you're welcome to the [Haskell
|
|
| 123 | +Wasm](https://matrix.to/#/#haskell.wasm:matrix.org) matrix room for
|
|
| 124 | +community support. |
| 1 | +# makefile_test/run_command is skipped when config.target_wrapper is
|
|
| 2 | +# not None, see test_common_work in testsuite/driver/testlib.py. for
|
|
| 3 | +# now just use this workaround to run custom test script here; ideally
|
|
| 4 | +# we'd fix test failures elsewhere and enable
|
|
| 5 | +# makefile_test/run_command for cross targets some day.
|
|
| 6 | +async def stub_run_command(name, way, cmd):
|
|
| 7 | + return await run_command(name, way, cmd)
|
|
| 8 | + |
|
| 9 | + |
|
| 10 | +# config.target_wrapper is prepended when running any command when
|
|
| 11 | +# testing a cross target, see simple_run in
|
|
| 12 | +# testsuite/driver/testlib.py. this is problematic when running a host
|
|
| 13 | +# test script. for now do this override; ideally we'd have clear
|
|
| 14 | +# host/target distinction for command invocations in the testsuite
|
|
| 15 | +# driver instead of just a command string.
|
|
| 16 | +def override_target_wrapper(name, opts):
|
|
| 17 | + opts.target_wrapper = ""
|
|
| 18 | + |
|
| 19 | + |
|
| 20 | +setTestOpts(
|
|
| 21 | + [
|
|
| 22 | + unless(arch("wasm32"), skip),
|
|
| 23 | + override_target_wrapper,
|
|
| 24 | + high_memory_usage,
|
|
| 25 | + ignore_stderr,
|
|
| 26 | + only_ways(["dyn"]),
|
|
| 27 | + extra_ways(["dyn"]),
|
|
| 28 | + ]
|
|
| 29 | +)
|
|
| 30 | + |
|
| 31 | + |
|
| 32 | +test(
|
|
| 33 | + "playground001",
|
|
| 34 | + [
|
|
| 35 | + # pretty heavyweight, just test one browser for now.
|
|
| 36 | + unless("FIREFOX_LAUNCH_OPTS" in ghc_env, skip),
|
|
| 37 | + extra_files(
|
|
| 38 | + [
|
|
| 39 | + "../../../.gitlab/hello.hs",
|
|
| 40 | + "../../../utils/jsffi/dyld.mjs",
|
|
| 41 | + "../../../utils/jsffi/post-link.mjs",
|
|
| 42 | + "../../../utils/jsffi/prelude.mjs",
|
|
| 43 | + "index.html",
|
|
| 44 | + "playground001.hs",
|
|
| 45 | + "playground001.js",
|
|
| 46 | + "playground001.sh",
|
|
| 47 | + ]
|
|
| 48 | + ),
|
|
| 49 | + ],
|
|
| 50 | + stub_run_command,
|
|
| 51 | + ['./playground001.sh "$FIREFOX_LAUNCH_OPTS"'],
|
|
| 52 | +) |
| 1 | +<!DOCTYPE html>
|
|
| 2 | +<html lang="en">
|
|
| 3 | + <head>
|
|
| 4 | + <meta charset="utf-8" />
|
|
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
| 6 | + <title>ghc-in-browser</title>
|
|
| 7 | + <link
|
|
| 8 | + rel="stylesheet"
|
|
| 9 | + href="https://cdn.jsdelivr.net/npm/modern-normalize/modern-normalize.min.css"
|
|
| 10 | + />
|
|
| 11 | + <style>
|
|
| 12 | + html,
|
|
| 13 | + body {
|
|
| 14 | + height: 100%;
|
|
| 15 | + }
|
|
| 16 | + body {
|
|
| 17 | + margin: 0;
|
|
| 18 | + font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
|
| 19 | + background: #0f172a;
|
|
| 20 | + color: #e5e7eb;
|
|
| 21 | + }
|
|
| 22 | + .app {
|
|
| 23 | + height: 100vh;
|
|
| 24 | + display: grid;
|
|
| 25 | + gap: 0.5rem;
|
|
| 26 | + padding: 0.5rem;
|
|
| 27 | + }
|
|
| 28 | + @media (min-width: 800px) {
|
|
| 29 | + .app {
|
|
| 30 | + grid-template-columns: 1fr 1fr;
|
|
| 31 | + }
|
|
| 32 | + }
|
|
| 33 | + @media (max-width: 799.98px) {
|
|
| 34 | + .app {
|
|
| 35 | + grid-template-rows: 1fr 1fr;
|
|
| 36 | + }
|
|
| 37 | + }
|
|
| 38 | + .pane {
|
|
| 39 | + background: #111827;
|
|
| 40 | + border: 1px solid #1f2937;
|
|
| 41 | + border-radius: 12px;
|
|
| 42 | + display: flex;
|
|
| 43 | + flex-direction: column;
|
|
| 44 | + min-height: 0;
|
|
| 45 | + }
|
|
| 46 | + header {
|
|
| 47 | + padding: 0.5rem 0.75rem;
|
|
| 48 | + border-bottom: 1px solid #1f2937;
|
|
| 49 | + font-weight: 600;
|
|
| 50 | + }
|
|
| 51 | + #editor {
|
|
| 52 | + flex: 1;
|
|
| 53 | + min-height: 0;
|
|
| 54 | + }
|
|
| 55 | + .right {
|
|
| 56 | + padding: 0.6rem;
|
|
| 57 | + gap: 0.6rem;
|
|
| 58 | + }
|
|
| 59 | + .controls {
|
|
| 60 | + display: flex;
|
|
| 61 | + gap: 0.5rem;
|
|
| 62 | + flex-wrap: wrap;
|
|
| 63 | + margin-bottom: 0.4rem;
|
|
| 64 | + }
|
|
| 65 | + .controls input[type="text"] {
|
|
| 66 | + flex: 1;
|
|
| 67 | + min-width: 200px;
|
|
| 68 | + background: #0b1020;
|
|
| 69 | + color: #e5e7eb;
|
|
| 70 | + border: 1px solid #223;
|
|
| 71 | + border-radius: 8px;
|
|
| 72 | + padding: 0.55rem;
|
|
| 73 | + }
|
|
| 74 | + .controls button {
|
|
| 75 | + background: #22c55e;
|
|
| 76 | + border: none;
|
|
| 77 | + border-radius: 8px;
|
|
| 78 | + padding: 0.55rem 0.85rem;
|
|
| 79 | + font-weight: 600;
|
|
| 80 | + cursor: pointer;
|
|
| 81 | + }
|
|
| 82 | + .outputs {
|
|
| 83 | + display: block;
|
|
| 84 | + }
|
|
| 85 | + .outputs .label {
|
|
| 86 | + font-size: 0.85rem;
|
|
| 87 | + opacity: 0.8;
|
|
| 88 | + margin: 0.35rem 0;
|
|
| 89 | + }
|
|
| 90 | + .outputs textarea {
|
|
| 91 | + display: block;
|
|
| 92 | + width: 100%;
|
|
| 93 | + min-height: 30vh;
|
|
| 94 | + background: #0b1020;
|
|
| 95 | + color: #d1fae5;
|
|
| 96 | + border: 1px solid #223;
|
|
| 97 | + border-radius: 8px;
|
|
| 98 | + padding: 0.6rem;
|
|
| 99 | + resize: vertical;
|
|
| 100 | + }
|
|
| 101 | + .stderr {
|
|
| 102 | + color: #fee2e2;
|
|
| 103 | + }
|
|
| 104 | + </style>
|
|
| 105 | + |
|
| 106 | + <script async type="module">
|
|
| 107 | + import * as monaco from "https://cdn.jsdelivr.net/npm/monaco-editor/+esm";
|
|
| 108 | + import {
|
|
| 109 | + ConsoleStdout,
|
|
| 110 | + File,
|
|
| 111 | + OpenFile,
|
|
| 112 | + PreopenDirectory,
|
|
| 113 | + WASI,
|
|
| 114 | + } from "https://esm.sh/gh/haskell-wasm/browser_wasi_shim";
|
|
| 115 | + import { DyLDBrowserHost, main } from "./dyld.mjs";
|
|
| 116 | + |
|
| 117 | + const rootfs = new PreopenDirectory("/", []);
|
|
| 118 | + |
|
| 119 | + const bsdtar_wasi = new WASI(
|
|
| 120 | + ["bsdtar.wasm", "-x"],
|
|
| 121 | + [],
|
|
| 122 | + [
|
|
| 123 | + new OpenFile(new File(new Uint8Array(), { readonly: true })),
|
|
| 124 | + ConsoleStdout.lineBuffered((msg) => console.info(msg)),
|
|
| 125 | + ConsoleStdout.lineBuffered((msg) => console.warn(msg)),
|
|
| 126 | + rootfs,
|
|
| 127 | + ],
|
|
| 128 | + { debug: false }
|
|
| 129 | + );
|
|
| 130 | + |
|
| 131 | + const [{ instance }, rootfs_bytes] = await Promise.all([
|
|
| 132 | + WebAssembly.instantiateStreaming(
|
|
| 133 | + fetch("https://haskell-wasm.github.io/bsdtar-wasm/bsdtar.wasm"),
|
|
| 134 | + { wasi_snapshot_preview1: bsdtar_wasi.wasiImport }
|
|
| 135 | + ),
|
|
| 136 | + fetch("./rootfs.tar.zst").then((r) => r.bytes()),
|
|
| 137 | + ]);
|
|
| 138 | + |
|
| 139 | + bsdtar_wasi.fds[0] = new OpenFile(
|
|
| 140 | + new File(rootfs_bytes, { readonly: true })
|
|
| 141 | + );
|
|
| 142 | + bsdtar_wasi.start(instance);
|
|
| 143 | + |
|
| 144 | + if (document.readyState === "loading") {
|
|
| 145 | + await new Promise((res) =>
|
|
| 146 | + document.addEventListener("DOMContentLoaded", res, { once: true })
|
|
| 147 | + );
|
|
| 148 | + }
|
|
| 149 | + |
|
| 150 | + window.editor = monaco.editor.create(document.getElementById("editor"), {
|
|
| 151 | + value: 'main :: IO ()\nmain = putStrLn "Hello, Haskell!"\n',
|
|
| 152 | + language: "haskell",
|
|
| 153 | + automaticLayout: true,
|
|
| 154 | + minimap: { enabled: false },
|
|
| 155 | + theme: "vs-dark",
|
|
| 156 | + fontSize: 14,
|
|
| 157 | + });
|
|
| 158 | + |
|
| 159 | + const dyld = await main({
|
|
| 160 | + rpc: new DyLDBrowserHost({
|
|
| 161 | + rootfs,
|
|
| 162 | + stdout: (msg) => {
|
|
| 163 | + document.getElementById("stdout").value += `${msg}\n`;
|
|
| 164 | + },
|
|
| 165 | + stderr: (msg) => {
|
|
| 166 | + document.getElementById("stderr").value += `${msg}\n`;
|
|
| 167 | + },
|
|
| 168 | + }),
|
|
| 169 | + searchDirs: [
|
|
| 170 | + "/tmp/clib",
|
|
| 171 | + "/tmp/hslib/lib/wasm32-wasi-ghc-9.15.20251024",
|
|
| 172 | + ],
|
|
| 173 | + mainSoPath: "/tmp/libplayground001.so",
|
|
| 174 | + args: ["libplayground001.so", "+RTS", "-c", "-RTS"],
|
|
| 175 | + isIserv: false,
|
|
| 176 | + });
|
|
| 177 | + const main_func = await dyld.exportFuncs.myMain("/tmp/hslib/lib");
|
|
| 178 | + |
|
| 179 | + document.getElementById("runBtn").addEventListener("click", async () => {
|
|
| 180 | + document.getElementById("runBtn").disabled = true;
|
|
| 181 | + |
|
| 182 | + try {
|
|
| 183 | + document.getElementById("stdout").value = "";
|
|
| 184 | + document.getElementById("stderr").value = "";
|
|
| 185 | + |
|
| 186 | + await main_func(
|
|
| 187 | + document.getElementById("ghcArgs").value,
|
|
| 188 | + editor.getValue()
|
|
| 189 | + );
|
|
| 190 | + } finally {
|
|
| 191 | + document.getElementById("runBtn").disabled = false;
|
|
| 192 | + }
|
|
| 193 | + });
|
|
| 194 | + |
|
| 195 | + document.getElementById("runBtn").disabled = false;
|
|
| 196 | + </script>
|
|
| 197 | + </head>
|
|
| 198 | + <body>
|
|
| 199 | + <div class="app">
|
|
| 200 | + <section class="pane">
|
|
| 201 | + <header>Haskell Source</header>
|
|
| 202 | + <div id="editor"></div>
|
|
| 203 | + </section>
|
|
| 204 | + |
|
| 205 | + <section class="pane right">
|
|
| 206 | + <header>Controls / Output</header>
|
|
| 207 | + <div class="controls">
|
|
| 208 | + <input
|
|
| 209 | + id="ghcArgs"
|
|
| 210 | + type="text"
|
|
| 211 | + placeholder="GHC args"
|
|
| 212 | + style="font-family: ui-monospace, Menlo, Consolas, monospace"
|
|
| 213 | + />
|
|
| 214 | + <button id="runBtn" disabled="true">Run</button>
|
|
| 215 | + </div>
|
|
| 216 | + <div class="outputs">
|
|
| 217 | + <div class="label">stdout</div>
|
|
| 218 | + <textarea
|
|
| 219 | + id="stdout"
|
|
| 220 | + readonly
|
|
| 221 | + style="font-family: ui-monospace, Menlo, Consolas, monospace"
|
|
| 222 | + ></textarea>
|
|
| 223 | + <div class="label">stderr</div>
|
|
| 224 | + <textarea
|
|
| 225 | + id="stderr"
|
|
| 226 | + class="stderr"
|
|
| 227 | + readonly
|
|
| 228 | + style="font-family: ui-monospace, Menlo, Consolas, monospace"
|
|
| 229 | + ></textarea>
|
|
| 230 | + </div>
|
|
| 231 | + </section>
|
|
| 232 | + </div>
|
|
| 233 | + </body>
|
|
| 234 | +</html> |
| 1 | +module Playground
|
|
| 2 | + ( myMain,
|
|
| 3 | + )
|
|
| 4 | +where
|
|
| 5 | + |
|
| 6 | +import Control.Monad
|
|
| 7 | +import Data.Coerce
|
|
| 8 | +import Data.IORef
|
|
| 9 | +import GHC
|
|
| 10 | +import GHC.Driver.Config.Diagnostic
|
|
| 11 | +import GHC.Driver.Errors
|
|
| 12 | +import GHC.Driver.Errors.Types
|
|
| 13 | +import GHC.Driver.Monad
|
|
| 14 | +import GHC.Plugins
|
|
| 15 | +import GHC.Runtime.Interpreter
|
|
| 16 | +import GHC.Utils.Exception
|
|
| 17 | +import GHC.Wasm.Prim
|
|
| 18 | + |
|
| 19 | +newtype JSFunction t = JSFunction JSVal
|
|
| 20 | + |
|
| 21 | +type ExportedMainFunction = JSString -> JSString -> IO ()
|
|
| 22 | + |
|
| 23 | +-- main entry point of playground001, returns a js async function that
|
|
| 24 | +-- takes ghc args and Main.hs content, interprets Main.hs and runs
|
|
| 25 | +-- Main.main.
|
|
| 26 | +myMain :: JSString -> IO (JSFunction ExportedMainFunction)
|
|
| 27 | +myMain js_libdir =
|
|
| 28 | + defaultErrorHandler defaultFatalMessager defaultFlushOut $ do
|
|
| 29 | + libdir <- evaluate $ fromJSString js_libdir
|
|
| 30 | + freeJSVal $ coerce js_libdir
|
|
| 31 | + -- we don't use runGhc since we want to share a session to be
|
|
| 32 | + -- reused.
|
|
| 33 | + session <- Session <$> newIORef undefined
|
|
| 34 | + -- save a fresh default dflags, otherwise user input ghc args are
|
|
| 35 | + -- not properly reset.
|
|
| 36 | + dflags0 <- flip reflectGhc session $ do
|
|
| 37 | + initGhcMonad (Just libdir)
|
|
| 38 | + dflags0 <- getSessionDynFlags
|
|
| 39 | + setSessionDynFlags $
|
|
| 40 | + dflags0
|
|
| 41 | + { ghcMode = CompManager,
|
|
| 42 | + backend = bytecodeBackend,
|
|
| 43 | + ghcLink = LinkInMemory,
|
|
| 44 | + verbosity = 1
|
|
| 45 | + }
|
|
| 46 | + getSessionDynFlags
|
|
| 47 | + -- this is always run in a forked thread. which is fine as long as
|
|
| 48 | + -- the sesssion is not reused concurrently, but it's up to the
|
|
| 49 | + -- caller in js to ensure that. we simply disable the run button
|
|
| 50 | + -- until each run completes in the playground ui logic.
|
|
| 51 | + toMainFunc $ \js_args js_src ->
|
|
| 52 | + defaultErrorHandler defaultFatalMessager defaultFlushOut $ do
|
|
| 53 | + args <- evaluate $ words $ fromJSString js_args
|
|
| 54 | + freeJSVal $ coerce js_args
|
|
| 55 | + writeFile f $ fromJSString js_src
|
|
| 56 | + freeJSVal $ coerce js_src
|
|
| 57 | + -- it's fine to call withCleanupSession since it just cleans up
|
|
| 58 | + -- tmpfs for now. in the future if it does more cleanup that
|
|
| 59 | + -- makes the session state invalid for reuse, just remove it;
|
|
| 60 | + -- everything will be cleaned up anyway when the browser tab is
|
|
| 61 | + -- closed
|
|
| 62 | + flip reflectGhc session $ withCleanupSession $ do
|
|
| 63 | + setSessionDynFlags dflags0
|
|
| 64 | + logger0 <- getLogger
|
|
| 65 | + (dflags1, _, dynamicFlagWarnings) <-
|
|
| 66 | + parseDynamicFlags logger0 dflags0 $ map noLoc args
|
|
| 67 | + setSessionDynFlags dflags1
|
|
| 68 | + logger1 <- getLogger
|
|
| 69 | + liftIO
|
|
| 70 | + $ printOrThrowDiagnostics
|
|
| 71 | + logger1
|
|
| 72 | + (initPrintConfig dflags1)
|
|
| 73 | + (initDiagOpts dflags1)
|
|
| 74 | + $ GhcDriverMessage
|
|
| 75 | + <$> dynamicFlagWarnings
|
|
| 76 | + setTargets =<< (: []) <$> guessTarget f Nothing Nothing
|
|
| 77 | + r <- load LoadAllTargets
|
|
| 78 | + when (failed r) $ fail "load returned Failed"
|
|
| 79 | + setContext [IIDecl $ simpleImportDecl $ mkModuleName "Main"]
|
|
| 80 | + fhv <- compileExprRemote "Main.main"
|
|
| 81 | + hsc_env <- getSession
|
|
| 82 | + liftIO $ evalIO (hscInterp hsc_env) fhv
|
|
| 83 | + where
|
|
| 84 | + f = "/tmp/Main.hs"
|
|
| 85 | + |
|
| 86 | +foreign import javascript "wrapper"
|
|
| 87 | + toMainFunc ::
|
|
| 88 | + ExportedMainFunction ->
|
|
| 89 | + IO (JSFunction ExportedMainFunction)
|
|
| 90 | + |
|
| 91 | +foreign export javascript "myMain"
|
|
| 92 | + myMain ::
|
|
| 93 | + JSString ->
|
|
| 94 | + IO
|
|
| 95 | + (JSFunction ExportedMainFunction) |
| 1 | +#!/usr/bin/env -S node
|
|
| 2 | + |
|
| 3 | +const puppeteer = require("puppeteer-core");
|
|
| 4 | +const fs = require("node:fs");
|
|
| 5 | +const path = require("node:path");
|
|
| 6 | + |
|
| 7 | +class Playground {
|
|
| 8 | + static #token = Symbol("Playground");
|
|
| 9 | + #browser;
|
|
| 10 | + #page;
|
|
| 11 | + |
|
| 12 | + static async create({ launchOpts, artifactDir }) {
|
|
| 13 | + const playground = new Playground(Playground.#token);
|
|
| 14 | + playground.#browser = await puppeteer.launch(launchOpts);
|
|
| 15 | + |
|
| 16 | + playground.#page = await playground.#browser.newPage();
|
|
| 17 | + await playground.#page.setRequestInterception(true);
|
|
| 18 | + playground.#page.on("request", async (req) => {
|
|
| 19 | + if (!req.url().startsWith("http://localhost")) {
|
|
| 20 | + return req.continue();
|
|
| 21 | + }
|
|
| 22 | + |
|
| 23 | + try {
|
|
| 24 | + const f = req.url().replace("http://localhost", artifactDir);
|
|
| 25 | + return req.respond({
|
|
| 26 | + status: 200,
|
|
| 27 | + contentType:
|
|
| 28 | + {
|
|
| 29 | + ".html": "text/html",
|
|
| 30 | + ".mjs": "application/javascript",
|
|
| 31 | + }[path.extname(f)] || "application/octet-stream",
|
|
| 32 | + body: await fs.promises.readFile(f),
|
|
| 33 | + });
|
|
| 34 | + } catch {
|
|
| 35 | + return req.abort();
|
|
| 36 | + }
|
|
| 37 | + });
|
|
| 38 | + |
|
| 39 | + await playground.#page.goto("http://localhost/index.html");
|
|
| 40 | + await playground.#page.locator("#runBtn:enabled").wait();
|
|
| 41 | + return playground;
|
|
| 42 | + }
|
|
| 43 | + |
|
| 44 | + async close() {
|
|
| 45 | + await this.#browser.close();
|
|
| 46 | + }
|
|
| 47 | + |
|
| 48 | + async runMain({ mainSrc, ghcArgs }) {
|
|
| 49 | + await Promise.all([
|
|
| 50 | + this.#page.evaluate((mainSrc) => editor.setValue(mainSrc), mainSrc),
|
|
| 51 | + this.#page.locator("#ghcArgs").fill(ghcArgs),
|
|
| 52 | + ]);
|
|
| 53 | + await this.#page.locator("#runBtn:enabled").click();
|
|
| 54 | + await this.#page.locator("#runBtn:enabled").wait();
|
|
| 55 | + |
|
| 56 | + const [stdout, stderr] = await Promise.all(
|
|
| 57 | + ["#stdout", "#stderr"].map((el) =>
|
|
| 58 | + this.#page
|
|
| 59 | + .locator(el)
|
|
| 60 | + .map((t) => t.value)
|
|
| 61 | + .wait()
|
|
| 62 | + )
|
|
| 63 | + );
|
|
| 64 | + |
|
| 65 | + return { stdout, stderr };
|
|
| 66 | + }
|
|
| 67 | + |
|
| 68 | + constructor(token) {
|
|
| 69 | + if (token !== Playground.#token) {
|
|
| 70 | + throw new Error("new Playground() is forbidden, use Playground.create()");
|
|
| 71 | + }
|
|
| 72 | + }
|
|
| 73 | +}
|
|
| 74 | + |
|
| 75 | +(async () => {
|
|
| 76 | + const playground = await Playground.create({
|
|
| 77 | + launchOpts: JSON.parse(process.argv[2]),
|
|
| 78 | + artifactDir: process.cwd(),
|
|
| 79 | + });
|
|
| 80 | + |
|
| 81 | + try {
|
|
| 82 | + const { stdout, stderr } = await playground.runMain({
|
|
| 83 | + mainSrc: await fs.promises.readFile("./hello.hs", { encoding: "utf-8" }),
|
|
| 84 | + ghcArgs: "-package ghc -v0",
|
|
| 85 | + });
|
|
| 86 | + process.stdout.write(stdout);
|
|
| 87 | + process.stderr.write(stderr);
|
|
| 88 | + } finally {
|
|
| 89 | + await playground.close();
|
|
| 90 | + }
|
|
| 91 | +})(); |
| 1 | +#!/usr/bin/env bash
|
|
| 2 | + |
|
| 3 | +set -euo pipefail
|
|
| 4 | + |
|
| 5 | +# also set this when building wasm32-wasi-ghc for production
|
|
| 6 | +# deployment of haskell playground, so all the .so files are
|
|
| 7 | +# optimized.
|
|
| 8 | +export WASM_SO_OPT="--debuginfo --low-memory-unused --strip-dwarf -Oz"
|
|
| 9 | + |
|
| 10 | +# we'll build a rootfs tarball that contains everything in tmp and
|
|
| 11 | +# extracts to /tmp, mapped from here
|
|
| 12 | +mkdir ./tmp
|
|
| 13 | + |
|
| 14 | +$TEST_HC \
|
|
| 15 | + -v0 \
|
|
| 16 | + -package ghc \
|
|
| 17 | + -shared -dynamic \
|
|
| 18 | + -no-keep-hi-files -no-keep-o-files \
|
|
| 19 | + -O2 \
|
|
| 20 | + playground001.hs -o ./tmp/libplayground001.so
|
|
| 21 | +rm -f ./*_stub.h ./playground001.hs
|
|
| 22 | + |
|
| 23 | +# /tmp/clib contains libc/libc++ .so files
|
|
| 24 | +cp -r "$(dirname "$TEST_CC")/../share/wasi-sysroot/lib/wasm32-wasi" ./tmp/clib
|
|
| 25 | +# trim unneeded stuff in c libdir
|
|
| 26 | +find ./tmp/clib -type f ! -name "*.so" -delete
|
|
| 27 | +rm -f \
|
|
| 28 | + ./tmp/clib/libsetjmp.so \
|
|
| 29 | + ./tmp/clib/libwasi-emulated-*.so
|
|
| 30 | + |
|
| 31 | +# /tmp/hslib/lib is the ghc libdir
|
|
| 32 | +mkdir ./tmp/hslib
|
|
| 33 | +cp -r "$($TEST_HC --print-libdir)" ./tmp/hslib/lib
|
|
| 34 | +# unregister Cabal/Cabal-syntax, too big
|
|
| 35 | +$GHC_PKG --no-user-package-db --global-package-db=./tmp/hslib/lib/package.conf.d unregister Cabal Cabal-syntax
|
|
| 36 | +$GHC_PKG --no-user-package-db --global-package-db=./tmp/hslib/lib/package.conf.d recache
|
|
| 37 | +# we only need non-profiling .dyn_hi/.so, trim as much as we can
|
|
| 38 | +find ./tmp/hslib/lib "(" \
|
|
| 39 | + -name "*.hi" \
|
|
| 40 | + -o -name "*.a" \
|
|
| 41 | + -o -name "*.p_hi" \
|
|
| 42 | + -o -name "libHS*_p.a" \
|
|
| 43 | + -o -name "*.p_dyn_hi" \
|
|
| 44 | + -o -name "libHS*_p*.so" \
|
|
| 45 | + -o -name "libHSrts*_debug*.so" \
|
|
| 46 | + ")" -delete
|
|
| 47 | +rm -rf \
|
|
| 48 | + ./tmp/hslib/lib/doc \
|
|
| 49 | + ./tmp/hslib/lib/html \
|
|
| 50 | + ./tmp/hslib/lib/latex \
|
|
| 51 | + ./tmp/hslib/lib/*.mjs \
|
|
| 52 | + ./tmp/hslib/lib/*.js \
|
|
| 53 | + ./tmp/hslib/lib/*.txt
|
|
| 54 | +# HS_SEARCHDIR is something like
|
|
| 55 | +# /tmp/hslib/lib/wasm32-wasi-ghc-9.15.20251024 which is the
|
|
| 56 | +# dynamic-library-dirs that contains all libHS*.so in one place, and
|
|
| 57 | +# also static libraries in per-unit directories
|
|
| 58 | +HS_SEARCHDIR=$(find ./tmp/hslib/lib -type f -name "*.so" -print0 | xargs -0 -n1 dirname | sort -u | sed "s|^\./|/|")
|
|
| 59 | +# hunt down the remaining bits of Cabal/Cabal-syntax. too bad there's
|
|
| 60 | +# no ghc-pkg uninstall.
|
|
| 61 | +rm -rf ."$HS_SEARCHDIR"/*Cabal*
|
|
| 62 | + |
|
| 63 | +# fix the hard coded search dir in index.html
|
|
| 64 | +SED_IS_GNU=$(sed --version &> /dev/null && echo 1 || echo 0)
|
|
| 65 | +if [[ $SED_IS_GNU == "1" ]]; then
|
|
| 66 | + sed -i "s|/tmp/hslib/lib/wasm32-wasi-ghc-9.15.20251024|$HS_SEARCHDIR|" ./index.html
|
|
| 67 | +else
|
|
| 68 | + sed -i "" "s|/tmp/hslib/lib/wasm32-wasi-ghc-9.15.20251024|$HS_SEARCHDIR|" ./index.html
|
|
| 69 | +fi
|
|
| 70 | + |
|
| 71 | +# also set ZSTD_NBTHREADS/ZSTD_CLEVEL when building for production
|
|
| 72 | +tar -cf ./rootfs.tar.zst --zstd tmp
|
|
| 73 | +rm -rf ./tmp
|
|
| 74 | + |
|
| 75 | +# pass puppeteer.launch() opts as json
|
|
| 76 | +exec ./playground001.js "$1" |
| 1 | -import Control.Exception
|
|
| 2 | -import Control.Monad.IO.Class
|
|
| 3 | -import Data.Maybe
|
|
| 4 | -import GHC
|
|
| 5 | -import GHC.Plugins
|
|
| 6 | -import GHC.Runtime.Interpreter
|
|
| 7 | -import System.Environment.Blank
|
|
| 8 | - |
|
| 9 | -main :: IO ()
|
|
| 10 | -main = do
|
|
| 11 | - [libdir] <- getArgs
|
|
| 12 | - defaultErrorHandler defaultFatalMessager defaultFlushOut $
|
|
| 13 | - runGhc (Just libdir) $
|
|
| 14 | - do
|
|
| 15 | - dflags0 <- getSessionDynFlags
|
|
| 16 | - let dflags1 =
|
|
| 17 | - dflags0
|
|
| 18 | - { ghcMode = CompManager,
|
|
| 19 | - backend = bytecodeBackend,
|
|
| 20 | - ghcLink = LinkInMemory
|
|
| 21 | - }
|
|
| 22 | - logger <- getLogger
|
|
| 23 | - (dflags2, _, _) <-
|
|
| 24 | - parseDynamicFlags logger dflags1 $
|
|
| 25 | - map noLoc ["-package", "ghc"]
|
|
| 26 | - _ <- setSessionDynFlags dflags2
|
|
| 27 | - addTarget =<< guessTarget "hello.hs" Nothing Nothing
|
|
| 28 | - _ <- load LoadAllTargets
|
|
| 29 | - setContext
|
|
| 30 | - [ IIDecl $ simpleImportDecl $ mkModuleName "Prelude",
|
|
| 31 | - IIDecl $ simpleImportDecl $ mkModuleName "Main"
|
|
| 32 | - ]
|
|
| 33 | - hsc_env <- getSession
|
|
| 34 | - fhv <- compileExprRemote "main"
|
|
| 35 | - liftIO $ evalIO (fromJust $ hsc_interp hsc_env) fhv |
| ... | ... | @@ -10,11 +10,3 @@ test('T26430', [ |
| 10 | 10 | extra_hc_opts('-L. -lT26430B')]
|
| 11 | 11 | , compile_and_run, ['']
|
| 12 | 12 | ) |
| 13 | - |
|
| 14 | -test('T26431', [
|
|
| 15 | - extra_files(['../../../.gitlab/hello.hs']),
|
|
| 16 | - extra_hc_opts('-package ghc'),
|
|
| 17 | - extra_run_opts(f'"{config.libdir}"'),
|
|
| 18 | - ignore_stderr]
|
|
| 19 | -, compile_and_run, ['']
|
|
| 20 | -) |
| ... | ... | @@ -285,7 +285,7 @@ function originFromServerAddress({ address, family, port }) { |
| 285 | 285 | }
|
| 286 | 286 | |
| 287 | 287 | // Browser/node portable code stays above this watermark.
|
| 288 | -const isNode = Boolean(globalThis?.process?.versions?.node);
|
|
| 288 | +const isNode = Boolean(globalThis?.process?.versions?.node && !globalThis.Deno);
|
|
| 289 | 289 | |
| 290 | 290 | // Too cumbersome to only import at use sites. Too troublesome to
|
| 291 | 291 | // factor out browser-only/node-only logic into different modules. For
|
| ... | ... | @@ -307,27 +307,27 @@ if (isNode) { |
| 307 | 307 | ws = require("ws");
|
| 308 | 308 | } catch {}
|
| 309 | 309 | } else {
|
| 310 | - wasi = await import(
|
|
| 311 | - "https://cdn.jsdelivr.net/npm/@bjorn3/browser_wasi_shim@0.4.2/dist/index.js"
|
|
| 312 | - );
|
|
| 310 | + wasi = await import("https://esm.sh/gh/haskell-wasm/browser_wasi_shim");
|
|
| 313 | 311 | }
|
| 314 | 312 | |
| 315 | 313 | // A subset of dyld logic that can only be run in the host node
|
| 316 | 314 | // process and has full access to local filesystem
|
| 317 | -class DyLDHost {
|
|
| 315 | +export class DyLDHost {
|
|
| 318 | 316 | // Deduped absolute paths of directories where we lookup .so files
|
| 319 | 317 | #rpaths = new Set();
|
| 320 | 318 | |
| 321 | - constructor() {
|
|
| 322 | - // Inherited pipe file descriptors from GHC
|
|
| 323 | - const out_fd = Number.parseInt(process.argv[4]),
|
|
| 324 | - in_fd = Number.parseInt(process.argv[5]);
|
|
| 325 | - |
|
| 319 | + constructor({ outFd, inFd }) {
|
|
| 320 | + // When running a non-iserv shared library with node, the DyLDHost
|
|
| 321 | + // instance is created without a pair of fds, so skip creation of
|
|
| 322 | + // readStream/writeStream, they won't be used anyway
|
|
| 323 | + if (!(typeof outFd === "number" && typeof inFd === "number")) {
|
|
| 324 | + return;
|
|
| 325 | + }
|
|
| 326 | 326 | this.readStream = stream.Readable.toWeb(
|
| 327 | - fs.createReadStream(undefined, { fd: in_fd })
|
|
| 327 | + fs.createReadStream(undefined, { fd: inFd })
|
|
| 328 | 328 | );
|
| 329 | 329 | this.writeStream = stream.Writable.toWeb(
|
| 330 | - fs.createWriteStream(undefined, { fd: out_fd })
|
|
| 330 | + fs.createWriteStream(undefined, { fd: outFd })
|
|
| 331 | 331 | );
|
| 332 | 332 | }
|
| 333 | 333 | |
| ... | ... | @@ -377,6 +377,72 @@ class DyLDHost { |
| 377 | 377 | }
|
| 378 | 378 | }
|
| 379 | 379 | |
| 380 | +// Runs in the browser and uses the in-memory vfs, doesn't do any RPC
|
|
| 381 | +// calls
|
|
| 382 | +export class DyLDBrowserHost {
|
|
| 383 | + // Deduped absolute paths of directories where we lookup .so files
|
|
| 384 | + #rpaths = new Set();
|
|
| 385 | + // The PreopenDirectory object of the root filesystem
|
|
| 386 | + rootfs;
|
|
| 387 | + // Continuations to output a single line to stdout/stderr
|
|
| 388 | + stdout;
|
|
| 389 | + stderr;
|
|
| 390 | + |
|
| 391 | + // Given canonicalized absolute file path, returns the File object,
|
|
| 392 | + // or null if absent
|
|
| 393 | + #readFile(p) {
|
|
| 394 | + const { ret, entry } = this.rootfs.dir.get_entry_for_path({
|
|
| 395 | + parts: p.split("/").filter((tok) => tok !== ""),
|
|
| 396 | + is_dir: false,
|
|
| 397 | + });
|
|
| 398 | + return ret === 0 ? entry : null;
|
|
| 399 | + }
|
|
| 400 | + |
|
| 401 | + constructor({ rootfs, stdout, stderr }) {
|
|
| 402 | + this.rootfs = rootfs
|
|
| 403 | + ? rootfs
|
|
| 404 | + : new wasi.PreopenDirectory("/", [["tmp", new wasi.Directory([])]]);
|
|
| 405 | + this.stdout = stdout ? stdout : (msg) => console.info(msg);
|
|
| 406 | + this.stderr = stderr ? stderr : (msg) => console.warn(msg);
|
|
| 407 | + }
|
|
| 408 | + |
|
| 409 | + // p must be canonicalized absolute path
|
|
| 410 | + async addLibrarySearchPath(p) {
|
|
| 411 | + this.#rpaths.add(p);
|
|
| 412 | + return null;
|
|
| 413 | + }
|
|
| 414 | + |
|
| 415 | + async findSystemLibrary(f) {
|
|
| 416 | + if (f.startsWith("/")) {
|
|
| 417 | + if (this.#readFile(f)) {
|
|
| 418 | + return f;
|
|
| 419 | + }
|
|
| 420 | + throw new Error(`findSystemLibrary(${f}): not found in /`);
|
|
| 421 | + }
|
|
| 422 | + |
|
| 423 | + for (const rpath of this.#rpaths) {
|
|
| 424 | + const r = `${rpath}/${f}`;
|
|
| 425 | + if (this.#readFile(r)) {
|
|
| 426 | + return r;
|
|
| 427 | + }
|
|
| 428 | + }
|
|
| 429 | + |
|
| 430 | + throw new Error(
|
|
| 431 | + `findSystemLibrary(${f}): not found in ${[...this.#rpaths]}`
|
|
| 432 | + );
|
|
| 433 | + }
|
|
| 434 | + |
|
| 435 | + async fetchWasm(p) {
|
|
| 436 | + const entry = this.#readFile(p);
|
|
| 437 | + const r = new Response(entry.data, {
|
|
| 438 | + headers: { "Content-Type": "application/wasm" },
|
|
| 439 | + });
|
|
| 440 | + // It's only fetched once, take the chance to prune it in vfs to save memory
|
|
| 441 | + entry.data = new Uint8Array();
|
|
| 442 | + return r;
|
|
| 443 | + }
|
|
| 444 | +}
|
|
| 445 | + |
|
| 380 | 446 | // Fulfill the same functionality as DyLDHost by doing fetch() calls
|
| 381 | 447 | // to respective RPC endpoints of a host http server. Also manages
|
| 382 | 448 | // WebSocket connections back to host.
|
| ... | ... | @@ -494,7 +560,7 @@ export class DyLDRPC { |
| 494 | 560 | |
| 495 | 561 | // Actual implementation of endpoints used by DyLDRPC
|
| 496 | 562 | class DyLDRPCServer {
|
| 497 | - #dyldHost = new DyLDHost();
|
|
| 563 | + #dyldHost;
|
|
| 498 | 564 | #server;
|
| 499 | 565 | #wss;
|
| 500 | 566 | |
| ... | ... | @@ -502,11 +568,15 @@ class DyLDRPCServer { |
| 502 | 568 | host,
|
| 503 | 569 | port,
|
| 504 | 570 | dyldPath,
|
| 505 | - libdir,
|
|
| 506 | - ghciSoPath,
|
|
| 571 | + searchDirs,
|
|
| 572 | + mainSoPath,
|
|
| 573 | + outFd,
|
|
| 574 | + inFd,
|
|
| 507 | 575 | args,
|
| 508 | 576 | redirectWasiConsole,
|
| 509 | 577 | }) {
|
| 578 | + this.#dyldHost = new DyLDHost({ outFd, inFd });
|
|
| 579 | + |
|
| 510 | 580 | this.#server = http.createServer(async (req, res) => {
|
| 511 | 581 | const origin = originFromServerAddress(await this.listening);
|
| 512 | 582 | |
| ... | ... | @@ -540,7 +610,7 @@ class DyLDRPCServer { |
| 540 | 610 | res.end(
|
| 541 | 611 | `
|
| 542 | 612 | import { DyLDRPC, main } from "./fs${dyldPath}";
|
| 543 | -const args = ${JSON.stringify({ libdir, ghciSoPath, args })};
|
|
| 613 | +const args = ${JSON.stringify({ searchDirs, mainSoPath, args, isIserv: true })};
|
|
| 544 | 614 | args.rpc = new DyLDRPC({origin: "${origin}", redirectWasiConsole: ${redirectWasiConsole}});
|
| 545 | 615 | args.rpc.opened.then(() => main(args));
|
| 546 | 616 | `
|
| ... | ... | @@ -829,11 +899,37 @@ class DyLD { |
| 829 | 899 | ),
|
| 830 | 900 | wasi.ConsoleStdout.lineBuffered((msg) => this.#rpc.stdout(msg)),
|
| 831 | 901 | wasi.ConsoleStdout.lineBuffered((msg) => this.#rpc.stderr(msg)),
|
| 902 | + // for ghci browser mode, default to an empty rootfs with
|
|
| 903 | + // /tmp
|
|
| 904 | + this.#rpc instanceof DyLDBrowserHost
|
|
| 905 | + ? this.#rpc.rootfs
|
|
| 906 | + : new wasi.PreopenDirectory("/", [["tmp", new wasi.Directory([])]]),
|
|
| 832 | 907 | ],
|
| 833 | 908 | { debug: false }
|
| 834 | 909 | );
|
| 835 | 910 | }
|
| 836 | 911 | |
| 912 | + // Both wasi implementations we use provide
|
|
| 913 | + // wasi.initialize(instance) to initialize a wasip1 reactor
|
|
| 914 | + // module. However, instance does not really need to be a
|
|
| 915 | + // WebAssembly.Instance object; the wasi implementations only need
|
|
| 916 | + // to access instance.exports.memory for the wasi syscalls to
|
|
| 917 | + // work.
|
|
| 918 | + //
|
|
| 919 | + // Given we'll reuse the same wasi object across different
|
|
| 920 | + // WebAssembly.Instance objects anyway and
|
|
| 921 | + // wasi.initialize(instance) can't be called more than once, we
|
|
| 922 | + // use this simple trick and pass a fake instance object that
|
|
| 923 | + // contains just enough info for the wasi implementation to
|
|
| 924 | + // initialize its internal state. Later when we load each wasm
|
|
| 925 | + // shared library, we can just manually invoke their
|
|
| 926 | + // initialization functions.
|
|
| 927 | + this.#wasi.initialize({
|
|
| 928 | + exports: {
|
|
| 929 | + memory: this.#memory,
|
|
| 930 | + },
|
|
| 931 | + });
|
|
| 932 | + |
|
| 837 | 933 | // Keep this in sync with rts/wasm/Wasm.S!
|
| 838 | 934 | for (let i = 1; i <= 10; ++i) {
|
| 839 | 935 | this.#regs[`__R${i}`] = new WebAssembly.Global({
|
| ... | ... | @@ -930,10 +1026,15 @@ class DyLD { |
| 930 | 1026 | async loadDLLs(packed) {
|
| 931 | 1027 | // Normalize input to an array of strings. When called from Haskell
|
| 932 | 1028 | // we pass a single JSString containing NUL-separated paths.
|
| 933 | - const paths = (typeof packed === "string"
|
|
| 934 | - ? (packed.length === 0 ? [] : packed.split("\0"))
|
|
| 935 | - : [packed] // tolerate an accidental single path object
|
|
| 936 | - ).filter((s) => s.length > 0).reverse();
|
|
| 1029 | + const paths = (
|
|
| 1030 | + typeof packed === "string"
|
|
| 1031 | + ? packed.length === 0
|
|
| 1032 | + ? []
|
|
| 1033 | + : packed.split("\0")
|
|
| 1034 | + : [packed]
|
|
| 1035 | + ) // tolerate an accidental single path object
|
|
| 1036 | + .filter((s) => s.length > 0)
|
|
| 1037 | + .reverse();
|
|
| 937 | 1038 | |
| 938 | 1039 | // Compute a single downsweep plan for the whole batch.
|
| 939 | 1040 | // Note: #downsweep mutates #loadedSos to break cycles and dedup.
|
| ... | ... | @@ -1154,22 +1255,6 @@ class DyLD { |
| 1154 | 1255 | throw new Error(`cannot handle export ${k} ${v}`);
|
| 1155 | 1256 | }
|
| 1156 | 1257 | |
| 1157 | - // We call wasi.initialize when loading libc.so, then reuse the
|
|
| 1158 | - // wasi instance globally. When loading later .so files, just
|
|
| 1159 | - // manually invoke _initialize().
|
|
| 1160 | - if (soname === "libc.so") {
|
|
| 1161 | - instance.exports.__wasm_apply_data_relocs();
|
|
| 1162 | - // wasm-ld forbits --export-memory with --shared, I don't know
|
|
| 1163 | - // why but this is sufficient to make things work
|
|
| 1164 | - this.#wasi.initialize({
|
|
| 1165 | - exports: {
|
|
| 1166 | - memory: this.#memory,
|
|
| 1167 | - _initialize: instance.exports._initialize,
|
|
| 1168 | - },
|
|
| 1169 | - });
|
|
| 1170 | - continue;
|
|
| 1171 | - }
|
|
| 1172 | - |
|
| 1173 | 1258 | // See
|
| 1174 | 1259 | // https://gitlab.haskell.org/haskell-wasm/llvm-project/-/blob/release/21.x/lld/wasm/Writer.cpp#L1451,
|
| 1175 | 1260 | // __wasm_apply_data_relocs is now optional so only call it if
|
| ... | ... | @@ -1180,7 +1265,7 @@ class DyLD { |
| 1180 | 1265 | // been called upon instantiation, see
|
| 1181 | 1266 | // Writer::createStartFunction().
|
| 1182 | 1267 | if (instance.exports.__wasm_apply_data_relocs) {
|
| 1183 | - instance.exports.__wasm_apply_data_relocs();
|
|
| 1268 | + instance.exports.__wasm_apply_data_relocs();
|
|
| 1184 | 1269 | }
|
| 1185 | 1270 | |
| 1186 | 1271 | instance.exports._initialize();
|
| ... | ... | @@ -1208,15 +1293,38 @@ class DyLD { |
| 1208 | 1293 | }
|
| 1209 | 1294 | }
|
| 1210 | 1295 | |
| 1211 | -export async function main({ rpc, libdir, ghciSoPath, args }) {
|
|
| 1296 | +// The main entry point of dyld that may be run on node/browser, and
|
|
| 1297 | +// may run either iserv defaultMain from the ghci library or an
|
|
| 1298 | +// alternative entry point from another shared library
|
|
| 1299 | +export async function main({
|
|
| 1300 | + rpc, // Handle the side effects of DyLD
|
|
| 1301 | + searchDirs, // Initial library search directories
|
|
| 1302 | + mainSoPath, // Could also be another shared library that's actually not ghci
|
|
| 1303 | + args, // WASI argv starting with the executable name. +RTS etc will be respected
|
|
| 1304 | + isIserv, // set to true when running iserv defaultServer
|
|
| 1305 | +}) {
|
|
| 1212 | 1306 | try {
|
| 1213 | 1307 | const dyld = new DyLD({
|
| 1214 | - args: ["dyld.so", ...args],
|
|
| 1308 | + args,
|
|
| 1215 | 1309 | rpc,
|
| 1216 | 1310 | });
|
| 1217 | - await dyld.addLibrarySearchPath(libdir);
|
|
| 1218 | - await dyld.loadDLLs(ghciSoPath);
|
|
| 1311 | + for (const libdir of searchDirs) {
|
|
| 1312 | + await dyld.addLibrarySearchPath(libdir);
|
|
| 1313 | + }
|
|
| 1314 | + await dyld.loadDLLs(mainSoPath);
|
|
| 1315 | + |
|
| 1316 | + // At this point, rts/ghc-internal are loaded, perform wasm shared
|
|
| 1317 | + // library specific RTS startup logic, see Note [JSFFI initialization]
|
|
| 1318 | + dyld.exportFuncs.__ghc_wasm_jsffi_init();
|
|
| 1319 | + |
|
| 1320 | + // We're not running iserv, just return the dyld instance so user
|
|
| 1321 | + // could use it to invoke their exported functions, and don't
|
|
| 1322 | + // perform cleanup (see finally block)
|
|
| 1323 | + if (!isIserv) {
|
|
| 1324 | + return dyld;
|
|
| 1325 | + }
|
|
| 1219 | 1326 | |
| 1327 | + // iserv-specific logic follows
|
|
| 1220 | 1328 | const reader = rpc.readStream.getReader();
|
| 1221 | 1329 | const writer = rpc.writeStream.getWriter();
|
| 1222 | 1330 | |
| ... | ... | @@ -1235,31 +1343,25 @@ export async function main({ rpc, libdir, ghciSoPath, args }) { |
| 1235 | 1343 | writer.write(new Uint8Array(buf));
|
| 1236 | 1344 | };
|
| 1237 | 1345 | |
| 1238 | - dyld.exportFuncs.__ghc_wasm_jsffi_init();
|
|
| 1239 | - await dyld.exportFuncs.defaultServer(cb_sig, cb_recv, cb_send);
|
|
| 1346 | + return await dyld.exportFuncs.defaultServer(cb_sig, cb_recv, cb_send);
|
|
| 1240 | 1347 | } finally {
|
| 1241 | - rpc.close();
|
|
| 1348 | + if (isIserv) {
|
|
| 1349 | + rpc.close();
|
|
| 1350 | + }
|
|
| 1242 | 1351 | }
|
| 1243 | 1352 | }
|
| 1244 | 1353 | |
| 1245 | -(async () => {
|
|
| 1246 | - if (!isNode) {
|
|
| 1247 | - return;
|
|
| 1248 | - }
|
|
| 1249 | - |
|
| 1250 | - const libdir = process.argv[2];
|
|
| 1251 | - const ghciSoPath = process.argv[3];
|
|
| 1252 | - const args = process.argv.slice(6);
|
|
| 1253 | - |
|
| 1354 | +// node-specific iserv-specific logic
|
|
| 1355 | +async function nodeMain({ searchDirs, mainSoPath, outFd, inFd, args }) {
|
|
| 1254 | 1356 | if (!process.env.GHCI_BROWSER) {
|
| 1255 | - const rpc = new DyLDHost();
|
|
| 1256 | - await main({
|
|
| 1357 | + const rpc = new DyLDHost({ outFd, inFd });
|
|
| 1358 | + return await main({
|
|
| 1257 | 1359 | rpc,
|
| 1258 | - libdir,
|
|
| 1259 | - ghciSoPath,
|
|
| 1360 | + searchDirs,
|
|
| 1361 | + mainSoPath,
|
|
| 1260 | 1362 | args,
|
| 1363 | + isIserv: true,
|
|
| 1261 | 1364 | });
|
| 1262 | - return;
|
|
| 1263 | 1365 | }
|
| 1264 | 1366 | |
| 1265 | 1367 | if (!ws) {
|
| ... | ... | @@ -1272,8 +1374,10 @@ export async function main({ rpc, libdir, ghciSoPath, args }) { |
| 1272 | 1374 | host: process.env.GHCI_BROWSER_HOST || "127.0.0.1",
|
| 1273 | 1375 | port: process.env.GHCI_BROWSER_PORT || 0,
|
| 1274 | 1376 | dyldPath: import.meta.filename,
|
| 1275 | - libdir,
|
|
| 1276 | - ghciSoPath,
|
|
| 1377 | + searchDirs,
|
|
| 1378 | + mainSoPath,
|
|
| 1379 | + outFd,
|
|
| 1380 | + inFd,
|
|
| 1277 | 1381 | args,
|
| 1278 | 1382 | redirectWasiConsole:
|
| 1279 | 1383 | process.env.GHCI_BROWSER_PUPPETEER_LAUNCH_OPTS ||
|
| ... | ... | @@ -1362,6 +1466,20 @@ export async function main({ rpc, libdir, ghciSoPath, args }) { |
| 1362 | 1466 | }
|
| 1363 | 1467 | |
| 1364 | 1468 | console.log(
|
| 1365 | - `Open ${origin}/main.html or import ${origin}/main.js to boot ghci`
|
|
| 1469 | + `Open ${origin}/main.html or import("${origin}/main.js") to boot ghci`
|
|
| 1366 | 1470 | );
|
| 1367 | -})(); |
|
| 1471 | +}
|
|
| 1472 | + |
|
| 1473 | +const isNodeMain = isNode && import.meta.filename === process.argv[1];
|
|
| 1474 | + |
|
| 1475 | +// node iserv as invoked by
|
|
| 1476 | +// GHC.Runtime.Interpreter.Wasm.spawnWasmInterp
|
|
| 1477 | +if (isNodeMain) {
|
|
| 1478 | + const clibdir = process.argv[2];
|
|
| 1479 | + const mainSoPath = process.argv[3];
|
|
| 1480 | + const outFd = Number.parseInt(process.argv[4]),
|
|
| 1481 | + inFd = Number.parseInt(process.argv[5]);
|
|
| 1482 | + const args = ["dyld.so", ...process.argv.slice(6)];
|
|
| 1483 | + |
|
| 1484 | + await nodeMain({ searchDirs: [clibdir], mainSoPath, outFd, inFd, args });
|
|
| 1485 | +} |