diff --git a/doc/ref/mloop.xml b/doc/ref/mloop.xml index 8b9aef83fa..2f8831aaf6 100644 --- a/doc/ref/mloop.xml +++ b/doc/ref/mloop.xml @@ -1,4 +1,5 @@ - + @@ -477,6 +478,7 @@ indicate that you are in a break loop. 1/0; Error, Rational operations: must not be zero +Stack trace: not in any function at *stdin*:2 you can enter 'quit;' to quit to outer loop ]]> diff --git a/etc/emscripten/README.md b/etc/emscripten/README.md index 083de668b6..430c575415 100644 --- a/etc/emscripten/README.md +++ b/etc/emscripten/README.md @@ -2,7 +2,10 @@ Build GAP as a WebAssembly module and serve it as a self-contained website. The terminal interface uses [xterm-pty](https://github.com/mame/xterm-pty), -so the resulting page behaves like a normal GAP REPL. +so the resulting page behaves like a normal GAP REPL. The page is styled +to match (Ubuntu fonts, GAP logo, crimson +accents, dark-mode support) and adds a side panel for moving files in and +out of the browser session, an examples menu, and a restart button. ## Quick start @@ -50,6 +53,81 @@ TTY device so `tcsetattr()` reaches the line discipline. Both are sensitive to the toolchain version (a newer emsdk leaves `tcsetattr` unhonoured, so typed input is echoed twice), so re-test the REPL when changing emsdk. +## The `io` package + +The wasm build has no `dlopen`, so a package kernel extension cannot be +loaded the usual way. `build.sh` works around this for `io` by compiling +its single C file into the GAP kernel and registering it in the +static-module table (`src/compstat.c`), so `LoadKernelExtension("io")` +finds it without `dlopen`. This is the GASMAN-safe approach: `io` ends up +in the one wasm module, so ASYNCIFY's link-time pass instruments it +uniformly and `emscripten_scan_registers()` sees its frames (unlike a +dynamically loaded side module). The mechanism is detailed in the +comments in `build.sh` around the `IO_OBJ` block, with the registration +helper in `register_static_module.py`. + +`io` is the kernel dependency of several otherwise pure-GAP packages +(simpcomp, fr, rcwa, unitlib, …), so linking it in lets them load. Its +file/directory/time functions work; its socket/fork/select functions +compile but fail at runtime, since wasm has no such facilities. + +## The page: file transfer, examples, restart + +GAP runs with its working directory set to `/home/web_user` in the +virtual filesystem (the GAP tree itself sits at `/`, passed via `-l /`). +That directory starts empty, so everything in it is the user's: uploads +land there, and anything GAP creates there (`PrintTo`, `LogTo`, …) shows +up in the page's Files panel for download. + +The design is shaped by one constraint. Once GAP's `main()` starts, the +worker thread never returns to its event loop: every terminal operation +blocks in `Atomics.wait` inside xterm-pty's `TtyClient` until the main +thread responds. So `postMessage` *to* the worker is never delivered +after startup, while `postMessage` *from* the worker (the tty request +channel itself) always works. Hence: + +- **Uploads** go through a `SharedArrayBuffer` mailbox (chunked, see the + protocol comment in `gap-worker.js`; the sending side is in + `gap-ui.js`). The worker drains it at the start of every terminal + operation — the moments it is provably awake. An upload made while GAP + sits idle at its prompt therefore completes on the next terminal + activity; in particular, the Files panel's "insert `Read("file");`" + button pastes keystrokes whose processing drains the mailbox before + GAP ever sees the Enter, so the file is always in place in time. +- **Downloads** are pushed: when GAP asks for input (output has settled, + files are complete), the worker rescans `/home/web_user` and posts new + or changed files to the page, which caches the bytes. A download click + is served from that cache — it never has to ask the possibly-blocked + worker. This is also why a freshly written file only appears in the + panel once GAP is back at a prompt (or asks for input). +- Inserted commands (file hints and the examples menu) are pasted + *without* a newline; the user always presses Enter, so nothing is + auto-executed. + +### Cache invalidation across redeployments + +Library files are cached in the browser's IndexedDB and reused on later +visits without revalidation. To stop a redeployment from pairing a new +kernel with stale cached library files, `assemble-website.sh` writes a +fresh `build-id` file into the site; `gap-fs.js` compares it (fetched +with `cache: no-cache`) against the copy stored in the cache and +discards the cache when it differs. The page footer also has a "Reset +cached data" link that drops the cache and any registered service +worker and reloads — the escape hatch for any stuck client state. + +Known limitations, all consequences of the same architecture: + +- Ctrl-C cannot interrupt a running computation: there are no signals, + the main thread cannot reach the worker's wasm heap, and GAP cannot + poll JavaScript state while computing. The Restart button (which + terminates and respawns the worker) is the only escape from a runaway + computation. The Files panel keeps its cached entries across a + restart, still downloadable; inserting one re-uploads it first. +- User files do not persist across reloads or restarts (syncing them to + IndexedDB would need the worker's event loop, which is starved). +- The terminal is sized to the window before GAP starts; GAP does not + notice later resizes (no SIGWINCH), though xterm itself reflows. + ## Hosting The xterm-pty terminal uses `SharedArrayBuffer`, which browsers only allow @@ -71,12 +149,13 @@ re-fetches resources through a service worker that adds the headers). | ---- | ---- | | `build-in-docker.sh` | One-stop entry point. Builds the image, runs `build.sh` inside, then `assemble-website.sh`. | | `Dockerfile` | Pinned `emscripten/emsdk:3.1.23` with autotools, python3, bison/byacc/m4, and a baked-in copy of the GAP package distribution tarball at `/opt/gap-packages.tar.gz`. | -| `build.sh` | Configures and builds GMP, zlib, and GAP itself for wasm. | +| `build.sh` | Configures and builds GMP, zlib, and GAP itself for wasm. Also statically links the `io` package's kernel module into the kernel (see below). | | `assemble-website.sh` | Copies the build outputs and data directories (`pkg`, `lib`, `grp`, …) into `web-example/`. | | `generate_gap_fs_json.py` | Reads file paths on stdin, writes `gap-fs.json` (the manifest of every file in the virtual FS). | | `startup_manifest.json` | List of files to fetch eagerly at startup, captured from a real GAP run. Anything not in this list is fetched lazily on first read. See "Updating the startup manifest" below for how to refresh it. | | `serve.py` | Local server that adds the COOP/COEP headers. | -| `web-template/` | Static UI: `index.html`, the worker scripts, the FS init shim, and the COOP/COEP service worker for hosts where you can't set headers. | +| `web-template/` | Static UI: `index.html`, the page logic (`gap-ui.js`), the worker scripts, the FS init shim, and the COOP/COEP service worker for hosts where you can't set headers. | +| `web-template/vendor/` | Pinned local copies of xterm, xterm-pty, the fit addon, the GAP logo and the Ubuntu fonts, so the deployed site has no CDN dependency. See `web-template/vendor/README.md` for versions and how to update them. | ## Updating the startup manifest @@ -111,8 +190,8 @@ To regenerate it after such changes: `window.fetchedUrls` is an array of every unique URL the worker requested. Chrome/Firefox provide a `copy()` console helper: `copy(JSON.stringify(fetchedUrls))` puts the JSON on your clipboard. -5. Strip non-GAP-FS entries (`gap.js`, `gap.wasm`, `gap-fs.json`, the - xterm CDN URLs) and write the result to +5. Strip non-GAP-FS entries (`gap.js`, `gap.wasm`, `gap-fs.json`, and any + `vendor/` assets) and write the result to `etc/emscripten/startup_manifest.json` (so it's checked in and gets picked up by the next `assemble-website.sh`). A `jq` filter that keeps just GAP filesystem paths: diff --git a/etc/emscripten/assemble-website.sh b/etc/emscripten/assemble-website.sh index 12e6f6a83b..4816d23d2f 100755 --- a/etc/emscripten/assemble-website.sh +++ b/etc/emscripten/assemble-website.sh @@ -26,7 +26,7 @@ done rm -rf "$OUT_DIR" mkdir -p "$OUT_DIR" -cp "$SCRIPT_DIR"/web-template/* "$OUT_DIR"/ +cp -R "$SCRIPT_DIR"/web-template/* "$OUT_DIR"/ cp "$SCRIPT_DIR"/startup_manifest.json "$OUT_DIR"/ cp gap.js gap.wasm gap-fs.json "$OUT_DIR"/ # Emscripten only emits a separate gap.worker.js for pthread builds; this @@ -41,20 +41,27 @@ cp LICENSE COPYRIGHT "$OUT_DIR"/ # loaded (if listed in startup_manifest.json) or lazily fetched on first # read by Emscripten's createLazyFile. # -# -L dereferences symlinks so the output tree is self-contained (a setup -# where pkg/X is a symlink into a separate checkout still works). Some -# packages ship dangling symlinks as build artefacts (e.g. pkg/vole's -# rust/target/*.dSYM); cp prints those and exits non-zero but still copies -# everything else, so we don't let that abort the run. The assertion below -# catches a genuinely incomplete copy. +# tar -h dereferences symlinks so the output tree is self-contained (a +# setup where pkg/X is a symlink into a separate checkout still works); +# --exclude .git keeps such checkouts' git internals out of the shipped +# site. Some packages ship dangling symlinks as build artefacts (e.g. +# pkg/vole's rust/target/*.dSYM); tar reports those and exits non-zero +# but still copies everything else, so we don't let that abort the run. +# The assertion below catches a genuinely incomplete copy. for d in pkg lib grp tst doc hpcgap dev benchmark; do - cp -RL "$d" "$OUT_DIR"/ 2>/dev/null || true + tar -c -h --exclude '.git' -f - "$d" 2>/dev/null | tar -x -C "$OUT_DIR" -f - || true done # pkg/log holds package test logs (.log/.err/.out) that are never read at # runtime; keep them out of the shipped site. rm -rf "$OUT_DIR/pkg/log" +# A fresh id per assembled site. gap-fs.js compares it against the copy +# stored in the browser's IndexedDB cache and discards the cache when it +# differs, so a redeployment can never pair a new kernel with stale +# cached library files. +echo "$(date -u +%Y%m%dT%H%M%SZ)-$$" > "$OUT_DIR/build-id" + # The library bootstrap must be present, or GAP 404s during startup. if [[ ! -f "$OUT_DIR/lib/init.g" ]]; then echo "Error: lib/init.g missing from $OUT_DIR after copy." >&2 diff --git a/etc/emscripten/build.sh b/etc/emscripten/build.sh index d9197b04b6..432957888f 100755 --- a/etc/emscripten/build.sh +++ b/etc/emscripten/build.sh @@ -96,9 +96,17 @@ if [[ ! -f GNUmakefile ]] || ! grep -q '/emcc' GNUmakefile; then # emcc would be regenerated by a non-executable JS shim; stale .o # files have the wrong architecture. Configure regenerates build/. rm -rf build ffgen + # PTHREAD_CFLAGS/PTHREAD_LIBS: configure unconditionally adopts + # -pthread (for the sake of native kernel extensions, see configure.ac), + # but under emcc that flag enables full USE_PTHREADS mode — shared + # wasm memory, a helper worker, and a slow path when combined with + # ALLOW_MEMORY_GROWTH. GAP itself is single-threaded here, and + # emscripten's libc provides single-threaded pthread API stubs, so + # pre-seeding the variables makes AX_PTHREAD settle on "no flags". emconfigure ./configure ABI=32 \ --with-gmp="$AUX_PREFIX" \ --with-zlib="$AUX_PREFIX" \ + PTHREAD_CFLAGS=" " PTHREAD_LIBS=" " \ LDFLAGS="-s ASYNCIFY=1 -O2" fi @@ -125,13 +133,76 @@ fi # Copy host-built generated sources into place cp native-build/build/c_*.c native-build/build/ffdata.* src/ +# Statically link the 'io' package's kernel module into the GAP kernel. +# +# The wasm build has no dlopen, so a package kernel extension cannot be +# loaded the usual way. But GAP's loader already supports STATIC modules: +# LoadKernelExtension("io") (in io's init.g) checks SHOW_STAT() -- the +# list of names in CompInitFuncs[] (src/compstat.c) -- and loads a match +# without dlopen (see lib/files.gd). So we compile io's single C file into +# the kernel and add it to that table; no change to GAP's loader or io's +# GAP code is needed. This is the safe approach for GASMAN: io ends up in +# the one wasm module, so ASYNCIFY's link-time pass instruments it +# uniformly with the rest of the kernel and emscripten_scan_registers +# sees its frames (unlike a dlopen'd side module). +# +# io is the kernel dependency of several otherwise pure-GAP packages +# (simpcomp, fr, rcwa, unitlib, ...). Its socket/fork/select-based +# functions compile (emscripten declares the symbols) but fail at runtime +# under wasm; its file/directory/time functions work. +IO_OBJ="" +if [[ -d pkg/io ]]; then + IO_PKG="$PWD/pkg/io" + # io's configure (autoconf feature probe) writes gen/pkgconfig.h, which + # io.c needs. FIND_GAP may fail without a fully built GAP in $BASEDIR + # and exit non-zero, but pkgconfig.h is still generated by then, so we + # tolerate the exit code and assert on the file instead. + ( + cd "$IO_PKG" + [[ -f configure ]] || ./autogen.sh + emconfigure ./configure --with-gaproot="$BASEDIR" || true + ) + if [[ ! -f "$IO_PKG/gen/pkgconfig.h" ]]; then + echo "Error: io's pkgconfig.h was not generated; cannot link io." >&2 + exit 1 + fi + # io.c (via gap_all.h) includes generated kernel headers. The normal + # build generates these before compiling any object; since we compile + # io.o ahead of that, generate them first. + emmake make build/version.h build/config.h + # Compile io.c, renaming its generic Init__Dynamic to the unique + # Init__io that the static-module table references. ASYNCIFY is a + # link-time pass, so it is not (and must not be) given here. + emcc -m32 -c "$IO_PKG/src/io.c" -o "$AUX_BUILD/io.o" \ + -DInit__Dynamic=Init__io \ + -Isrc -Ibuild -Isrc/extra \ + -I"$IO_PKG/gen" -I"$IO_PKG/src" \ + -I"$AUX_PREFIX/include" \ + -fPIC -fno-strict-aliasing -O2 + python3 etc/emscripten/register_static_module.py io src/compstat.c + IO_OBJ="$AUX_BUILD/io.o" +fi + # Build the file list that will be served. -L follows symlinks so that # users' local development setups (e.g. replacing pkg/foo with a symlink # to a git checkout under git/foo) are picked up; -type f then drops any # symlinks themselves. find -L pkg lib grp tst doc hpcgap dev benchmark -type f ! -path 'pkg/log/*' \ + ! -path '*/.git/*' \ | python3 etc/emscripten/generate_gap_fs_json.py +# Emscripten only emits gap.worker.js for pthread builds; remove any +# leftover from a previous configuration so assemble-website.sh can't +# ship a stale one. +rm -f gap.worker.js + +# Memory: start small and grow on demand (sbrk stays contiguous, which +# GASMAN's workspace extension requires, since wasm memory grows in +# place). A 2GB up-front allocation was refused outright on iOS. +# $IO_OBJ (if set) is the statically-linked io module object; it goes on +# the link line, where the Init__io reference from src/compstat.c pulls it +# in. LDFLAGS flows into GAP_LDFLAGS (see Makefile.rules), which is part of +# the final link command. emmake make -j"$JOBS" \ - LDFLAGS="-lidbfs.js -s ASYNCIFY=1 -sTOTAL_STACK=32mb -sASYNCIFY_STACK_SIZE=32000000 -sINITIAL_MEMORY=2048mb -O2" \ + LDFLAGS="-lidbfs.js -s ASYNCIFY=1 -sTOTAL_STACK=32mb -sASYNCIFY_STACK_SIZE=32000000 -sINITIAL_MEMORY=256mb -sALLOW_MEMORY_GROWTH=1 -sMAXIMUM_MEMORY=2048mb $IO_OBJ -O2" \ EXEEXT=".html" diff --git a/etc/emscripten/register_static_module.py b/etc/emscripten/register_static_module.py new file mode 100644 index 0000000000..59fc281f23 --- /dev/null +++ b/etc/emscripten/register_static_module.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Register a package kernel module as a static module in src/compstat.c. + +GAP's static-module table (CompInitFuncs[] in src/compstat.c) is the list +of init functions that SHOW_STAT() reports; LoadKernelExtension finds a +module there and loads it without dlopen (which the wasm build lacks). +This adds an entry for a module whose init function has been renamed to +Init__ (via -DInit__Dynamic=Init__ when compiling it). + +Idempotent: running twice for the same module is a no-op. Usage: + register_static_module.py [path/to/compstat.c] +""" +import re +import sys + +name = sys.argv[1] +path = sys.argv[2] if len(sys.argv) > 2 else "src/compstat.c" +init = "Init__" + name + +src = open(path).read() +if init in src: + sys.exit(0) # already registered + +# Add the extern declaration after the last existing one. +externs = list(re.finditer(r"extern StructInitInfo \* Init__\w+\(void\);\n", src)) +if not externs: + sys.exit("register_static_module: no extern declarations found in " + path) +at = externs[-1].end() +src = src[:at] + "extern StructInitInfo * %s(void);\n" % init + src[at:] + +# Add the entry before the 0 terminator of CompInitFuncs[]. +src, n = re.subn(r"\n(\s*)0(,?\n\};)", r"\n\1%s,\n\g<1>0\2" % init, src, count=1) +if n != 1: + sys.exit("register_static_module: could not find CompInitFuncs terminator in " + path) + +open(path, "w").write(src) diff --git a/etc/emscripten/serve.py b/etc/emscripten/serve.py index 7d089f0c54..5afe9cb8af 100755 --- a/etc/emscripten/serve.py +++ b/etc/emscripten/serve.py @@ -18,6 +18,9 @@ class CrossOriginIsolatedHandler(http.server.SimpleHTTPRequestHandler): def end_headers(self): self.send_header("Cross-Origin-Opener-Policy", "same-origin") self.send_header("Cross-Origin-Embedder-Policy", "require-corp") + # Without this, browsers cache the UI files heuristically and + # edits to web-example/ don't show up on reload. + self.send_header("Cache-Control", "no-cache") super().end_headers() diff --git a/etc/emscripten/web-template/coi-serviceworker.js b/etc/emscripten/web-template/coi-serviceworker.js index 2e76c2137d..9901474cc3 100755 --- a/etc/emscripten/web-template/coi-serviceworker.js +++ b/etc/emscripten/web-template/coi-serviceworker.js @@ -1,4 +1,4 @@ -/*! coi-serviceworker v0.1.6 - Guido Zuidhof, licensed under MIT */ +/*! coi-serviceworker v0.1.7 - Guido Zuidhof and contributors, licensed under MIT */ let coepCredentialless = false; if (typeof window === 'undefined') { self.addEventListener("install", () => self.skipWaiting()); @@ -43,6 +43,9 @@ if (typeof window === 'undefined') { newHeaders.set("Cross-Origin-Embedder-Policy", coepCredentialless ? "credentialless" : "require-corp" ); + if (!coepCredentialless) { + newHeaders.set("Cross-Origin-Resource-Policy", "cross-origin"); + } newHeaders.set("Cross-Origin-Opener-Policy", "same-origin"); return new Response(response.body, { @@ -57,23 +60,46 @@ if (typeof window === 'undefined') { } else { (() => { + const reloadedBySelf = window.sessionStorage.getItem("coiReloadedBySelf"); + window.sessionStorage.removeItem("coiReloadedBySelf"); + const coepDegrading = (reloadedBySelf == "coepdegrade"); + // You can customize the behavior of this script through a global `coi` variable. const coi = { - shouldRegister: () => true, + shouldRegister: () => !reloadedBySelf, shouldDeregister: () => false, - coepCredentialless: () => false, + coepCredentialless: () => true, + coepDegrade: () => true, doReload: () => window.location.reload(), quiet: false, ...window.coi }; const n = navigator; + const controlling = n.serviceWorker && n.serviceWorker.controller; + + // Record the failure if the page is served by serviceWorker. + if (controlling && !window.crossOriginIsolated) { + window.sessionStorage.setItem("coiCoepHasFailed", "true"); + } + const coepHasFailed = window.sessionStorage.getItem("coiCoepHasFailed"); - if (n.serviceWorker && n.serviceWorker.controller) { + if (controlling) { + // Reload only on the first failure. + const reloadToDegrade = coi.coepDegrade() && !( + coepDegrading || window.crossOriginIsolated + ); n.serviceWorker.controller.postMessage({ type: "coepCredentialless", - value: coi.coepCredentialless(), + value: (reloadToDegrade || coepHasFailed && coi.coepDegrade()) + ? false + : coi.coepCredentialless(), }); + if (reloadToDegrade) { + !coi.quiet && console.log("Reloading page to degrade COEP."); + window.sessionStorage.setItem("coiReloadedBySelf", "coepdegrade"); + coi.doReload("coepdegrade"); + } if (coi.shouldDeregister()) { n.serviceWorker.controller.postMessage({ type: "deregister" }); @@ -89,27 +115,32 @@ if (typeof window === 'undefined') { return; } - // In some environments (e.g. Chrome incognito mode) this won't be available - if (n.serviceWorker) { - n.serviceWorker.register(window.document.currentScript.src).then( - (registration) => { - !coi.quiet && console.log("COOP/COEP Service Worker registered", registration.scope); + // In some environments (e.g. Firefox private mode) this won't be available + if (!n.serviceWorker) { + !coi.quiet && console.error("COOP/COEP Service Worker not registered, perhaps due to private mode."); + return; + } + + n.serviceWorker.register(window.document.currentScript.src).then( + (registration) => { + !coi.quiet && console.log("COOP/COEP Service Worker registered", registration.scope); - registration.addEventListener("updatefound", () => { - !coi.quiet && console.log("Reloading page to make use of updated COOP/COEP Service Worker."); - coi.doReload(); - }); + registration.addEventListener("updatefound", () => { + !coi.quiet && console.log("Reloading page to make use of updated COOP/COEP Service Worker."); + window.sessionStorage.setItem("coiReloadedBySelf", "updatefound"); + coi.doReload(); + }); - // If the registration is active, but it's not controlling the page - if (registration.active && !n.serviceWorker.controller) { - !coi.quiet && console.log("Reloading page to make use of COOP/COEP Service Worker."); - coi.doReload(); - } - }, - (err) => { - !coi.quiet && console.error("COOP/COEP Service Worker failed to register:", err); + // If the registration is active, but it's not controlling the page + if (registration.active && !n.serviceWorker.controller) { + !coi.quiet && console.log("Reloading page to make use of COOP/COEP Service Worker."); + window.sessionStorage.setItem("coiReloadedBySelf", "notcontrolling"); + coi.doReload(); } - ); - } + }, + (err) => { + !coi.quiet && console.error("COOP/COEP Service Worker failed to register:", err); + } + ); })(); } diff --git a/etc/emscripten/web-template/gap-fs.js b/etc/emscripten/web-template/gap-fs.js index ca8ea4f23d..5ad7eaac01 100644 --- a/etc/emscripten/web-template/gap-fs.js +++ b/etc/emscripten/web-template/gap-fs.js @@ -30,6 +30,13 @@ self.Module.preRun = self.Module.preRun || []; self.Module.preRun.push(function() { addRunDependency('gap_fs_init'); + // GAP starts in /home/web_user, which stays empty here, so anything + // under it is user-created (uploads, PrintTo output, ...). That keeps + // user files cleanly apart from the GAP tree at /; gap-worker.js + // passes "-l /" so GAP still finds its root. + FS.mkdirTree('/home/web_user'); + FS.chdir('/home/web_user'); + async function initFS() { try { const mapRes = await fetch('gap-fs.json'); @@ -51,14 +58,52 @@ self.Module.preRun.push(function() { FS.mount(IDBFS, {}, '/gap_idb_cache'); FS.syncfs(true, async function(err) { + var needsSave = false; + + // Discard the cache when the site was redeployed: a + // library file cached from one build must never be + // paired with the kernel of another. build-id is + // written by assemble-website.sh; a site without one + // (e.g. hand-assembled) keeps its cache indefinitely. + var storedId = null; + try { + storedId = new TextDecoder().decode( + FS.readFile('/gap_idb_cache/.build-id')); + } catch (e) {} + var buildId = null; + try { + const idRes = await fetch('build-id', { cache: 'no-cache' }); + if (idRes.ok) buildId = (await idRes.text()).trim(); + } catch (e) {} + if (buildId === null) { + console.info("gap-fs: no build-id served; reusing any cached files"); + } else if (storedId !== buildId) { + if (storedId !== null) { + console.info("gap-fs: site updated (" + storedId + + " -> " + buildId + "); discarding cached files"); + } + (function wipe(dir) { + FS.readdir(dir).forEach(function(name) { + if (name === '.' || name === '..') return; + var p = dir + '/' + name; + if (FS.isDir(FS.stat(p).mode)) { + wipe(p); + FS.rmdir(p); + } else { + FS.unlink(p); + } + }); + })('/gap_idb_cache'); + FS.writeFile('/gap_idb_cache/.build-id', buildId); + needsSave = true; + } + fileList.forEach(function(appPath) { var parts = appPath.split('/'); parts.pop(); var parentDir = '/' + parts.join('/'); try { FS.mkdirTree('/gap_idb_cache' + parentDir); } catch(e) {} }); - - var needsSave = false; var startupSet = new Set(); try { diff --git a/etc/emscripten/web-template/gap-ui.js b/etc/emscripten/web-template/gap-ui.js new file mode 100644 index 0000000000..53c8f17317 --- /dev/null +++ b/etc/emscripten/web-template/gap-ui.js @@ -0,0 +1,429 @@ +// Main-thread logic for the GAP-in-the-browser page: terminal/worker +// lifecycle (including restart), the upload mailbox sender, the files +// panel, the examples menu, and the loading progress notice. +// +// The worker side (gap-worker.js) explains the central constraint: after +// startup the worker only runs during terminal operations, blocking in +// Atomics.wait the rest of the time. So uploads go through a +// SharedArrayBuffer mailbox the worker drains on terminal activity, and +// the worker pushes copies of every user file to this thread, so +// downloads are served from a local cache and never need to ask a +// (possibly blocked) worker. + +"use strict"; + +// ---- upload mailbox protocol; keep in sync with gap-worker.js ---- +const MB_STATE = 0, MB_LEN = 1, MB_FLAGS = 2; +const MB_IDLE = 0, MB_READY = 1, MB_CONSUMED = 2; +const MB_FLAG_HEADER = 1, MB_FLAG_FINAL = 2, MB_FLAG_END = 4; +const MB_CTRL_BYTES = 16; +const MB_DATA_BYTES = 1 << 20; + +// Multi-line examples: every line except the last is submitted as it is +// pasted (the newline acts as Enter), so earlier lines run immediately — +// keep them to setup (silenced with ";;" or printing the definition); +// only the last line waits for the user's Enter. +const EXAMPLES = [ + { label: "Intersect two permutation groups", + code: 'G := Group((1,2,3)(4,5,6), (1,4), (2,5), (3,6));\n' + + 'H := Group((1,2,4,6), (4,6));\n' + + 'Intersection(G, H);' }, + { label: "Character table of A5", + code: 'Display(CharacterTable(AlternatingGroup(5)));' }, + { label: "The groups of order 12", + code: 'List(AllSmallGroups(12), StructureDescription);' }, + { label: "Factorise a Fermat number", + code: 'Factors(2^64 + 1);' }, + { label: "A finitely presented group", + code: 'F := FreeGroup("a", "b");;\nG := F / [F.1^2, F.2^3, (F.1*F.2)^5];; Size(G);' }, +]; + +const loadingEl = document.getElementById("loading"); +const unsupportedEl = document.getElementById("unsupported"); +const terminalEl = document.getElementById("terminal"); +const restartBtn = document.getElementById("restart"); +const uploadInput = document.getElementById("upload-input"); +const uploadBtn = document.getElementById("upload-button"); +const fileListEl = document.getElementById("file-list"); +const fileEmptyEl = document.getElementById("files-empty"); +const examplesEl = document.getElementById("example-list"); + +// URLs reported by gap-fs.js's fetch/XHR instrumentation, for rebuilding +// startup_manifest.json (see README.md). Inspect from the devtools +// console with copy(JSON.stringify(fetchedUrls)). +window.fetchedUrls = []; +const fetchedSet = new Set(); + +// Number of files the startup manifest will fetch on a cold visit, for +// the progress notice. 0 (missing/empty manifest) keeps the generic text. +let manifestTotal = 0; +fetch("startup_manifest.json") + .then((r) => (r.ok ? r.json() : [])) + .then((list) => { manifestTotal = list.length; }) + .catch(() => {}); + +// The files panel cache: path -> entry. Entries survive a session restart +// (status "previous"): the bytes are still here, so they stay +// downloadable, and inserting their Read command first re-uploads them +// into the new session. +// { size, data: Uint8Array, status: "transferring"|"ready"|"previous" } +const fileEntries = new Map(); + +// Per-session state; replaced wholesale by startSession() so that any +// in-flight async sender from a dead session aborts cleanly. +let session = null; + +function gapQuote(path) { + return '"' + path.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"'; +} + +function humanSize(n) { + if (n < 1024) return n + " B"; + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " kB"; + return (n / (1024 * 1024)).toFixed(1) + " MB"; +} + +// Paste text at GAP's prompt. Deliberately no trailing newline: the user +// presses Enter, so nothing is ever auto-executed. +function insertAtPrompt(text) { + if (session === null) return; + session.xterm.paste(text); + session.xterm.focus(); +} + +// --------------------------------------------------------------------- +// Upload sender. Writes one chunk whenever the worker has consumed the +// previous one; never blocks the main thread (Atomics.wait is forbidden +// here, so it uses Atomics.waitAsync where available and polling +// otherwise). The worker only drains on terminal activity, so a transfer +// started while GAP sits at its prompt completes on the next keystroke — +// in particular, the keystrokes of a pasted Read command arrive after +// the drain runs, so the file is always in place before Enter. + +function waitMailboxState(s, want) { + return new Promise((resolve, reject) => { + const check = () => { + if (s.dead) { + reject(new Error("session restarted")); + return; + } + const cur = Atomics.load(s.mbCtrl, MB_STATE); + if (cur === want) { + resolve(); + return; + } + if (Atomics.waitAsync) { + const r = Atomics.waitAsync(s.mbCtrl, MB_STATE, cur, 1000); + if (r.async) r.value.then(check); + else check(); + } else { + setTimeout(check, 10); + } + }; + check(); + }); +} + +function writeChunk(s, bytes, flags) { + s.mbData.set(bytes, 0); + s.mbCtrl[MB_LEN] = bytes.length; + s.mbCtrl[MB_FLAGS] = flags; + Atomics.store(s.mbCtrl, MB_STATE, MB_READY); + Atomics.notify(s.mbCtrl, MB_STATE); +} + +// files: [{ name, data: Uint8Array }] +async function sendFiles(files) { + const s = session; + for (const f of files) { + fileEntries.set(f.name, { size: f.data.length, data: f.data, + status: "transferring" }); + s.uploadQueue.push(f); + } + renderFiles(); + if (s.senderActive) return; + s.senderActive = true; + try { + await waitMailboxState(s, MB_IDLE); + while (s.uploadQueue.length > 0) { + const f = s.uploadQueue.shift(); + const header = new TextEncoder().encode( + JSON.stringify({ name: f.name, size: f.data.length })); + writeChunk(s, header, MB_FLAG_HEADER); + await waitMailboxState(s, MB_CONSUMED); + let off = 0; + do { + const n = Math.min(MB_DATA_BYTES, f.data.length - off); + const last = off + n >= f.data.length; + writeChunk(s, f.data.subarray(off, off + n), + last ? MB_FLAG_FINAL : 0); + off += n; + await waitMailboxState(s, MB_CONSUMED); + } while (off < f.data.length); + } + // Close the session; the worker hands the mailbox back as MB_IDLE. + writeChunk(s, new Uint8Array(0), MB_FLAG_END); + } catch (e) { + // Only a restart gets here; the entries were already marked + // "previous" by the restart handler. + if (!s.dead) throw e; + } finally { + s.senderActive = false; + } +} + +// --------------------------------------------------------------------- +// Files panel + +function downloadEntry(path) { + const entry = fileEntries.get(path); + const blob = new Blob([entry.data]); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = path.split("/").pop(); + a.click(); + URL.revokeObjectURL(a.href); +} + +function insertEntry(path) { + const entry = fileEntries.get(path); + if (entry.status === "previous") { + // From a previous session: put it back first. The re-upload drains + // before the pasted command's Enter can be processed. + sendFiles([{ name: path, data: entry.data }]); + } + insertAtPrompt("Read(" + gapQuote(path) + ");"); +} + +function renderFiles() { + fileListEl.textContent = ""; + fileEmptyEl.style.display = fileEntries.size === 0 ? "" : "none"; + const paths = Array.from(fileEntries.keys()).sort(); + for (const path of paths) { + const entry = fileEntries.get(path); + const li = document.createElement("li"); + + const nameSpan = document.createElement("span"); + nameSpan.className = "file-name"; + nameSpan.textContent = path; + nameSpan.title = path; + + const metaSpan = document.createElement("span"); + metaSpan.className = "file-meta"; + metaSpan.textContent = humanSize(entry.size) + + (entry.status === "transferring" ? " · sending…" + : entry.status === "failed" ? " · failed" + : entry.status === "previous" ? " · previous session" : ""); + + const insertB = document.createElement("button"); + insertB.className = "icon-button"; + insertB.textContent = "↳"; + insertB.title = "Insert Read(" + gapQuote(path) + "); at the prompt" + + (entry.status === "previous" ? " (re-uploads the file first)" : ""); + insertB.addEventListener("click", () => insertEntry(path)); + + const downloadB = document.createElement("button"); + downloadB.className = "icon-button"; + downloadB.textContent = "⬇"; + downloadB.title = "Download " + path; + downloadB.addEventListener("click", () => downloadEntry(path)); + + const text = document.createElement("div"); + text.className = "file-text"; + text.append(nameSpan, metaSpan); + li.append(text, insertB, downloadB); + fileListEl.append(li); + } +} + +uploadBtn.addEventListener("click", () => uploadInput.click()); +uploadInput.addEventListener("change", async () => { + const files = []; + for (const f of uploadInput.files) { + // Basename only: uploads land directly in GAP's working directory. + const name = f.name.split(/[/\\]/).pop(); + if (name === "") continue; + const data = new Uint8Array(await f.arrayBuffer()); + files.push({ name: name, data: data }); + } + uploadInput.value = ""; + if (files.length > 0) sendFiles(files); +}); + +// --------------------------------------------------------------------- +// Examples + +for (const ex of EXAMPLES) { + const li = document.createElement("li"); + const b = document.createElement("button"); + b.className = "example-button"; + b.textContent = ex.label; + b.title = ex.code; + b.addEventListener("click", () => insertAtPrompt(ex.code)); + li.append(b); + examplesEl.append(li); +} + +// --------------------------------------------------------------------- +// Session lifecycle + +function handleWorkerMessage(ev) { + const data = ev.data; + if (!data || !data.type) return; + switch (data.type) { + case "gap-fetched": + if (!fetchedSet.has(data.url)) { + fetchedSet.add(data.url); + window.fetchedUrls.push(data.url); + if (/^(pkg|lib|grp|tst|doc|hpcgap|dev|benchmark)\//.test(data.url)) { + session.fetchCount++; + if (!session.started) { + const progress = "fetched " + session.fetchCount + + (manifestTotal > 0 ? " of ~" + manifestTotal : "") + " files"; + loadingEl.textContent = "Loading GAP… " + progress + "."; + // \r keeps overwriting one progress line in the terminal. + session.xterm.write("\rLoading GAP… " + progress); + } + } + } + break; + case "gap-user-files": + for (const f of data.changed) { + fileEntries.set(f.path, { size: f.size, data: f.data, + status: "ready" }); + } + for (const p of data.removed) { + // Deleted inside GAP; drop it from the panel too. + fileEntries.delete(p); + } + renderFiles(); + break; + case "gap-file-uploaded": { + const entry = fileEntries.get(data.name); + if (entry && entry.status === "transferring") entry.status = "ready"; + renderFiles(); + break; + } + case "gap-file-error": + console.error("Upload failed in worker:", data); + for (const entry of fileEntries.values()) { + if (entry.status === "transferring") entry.status = "failed"; + } + renderFiles(); + break; + } +} + +function startSession() { + loadingEl.style.display = ""; + loadingEl.textContent = "Loading GAP… The first visit downloads several " + + "tens of megabytes; later visits are cached by your browser and " + + "start much faster."; + + const xterm = new Terminal({ + fontFamily: '"Ubuntu Mono", Menlo, Consolas, monospace', + fontSize: 15, + cursorBlink: true, + theme: { background: "#1d1d1d", foreground: "#e6e6e6" }, + }); + const fitAddon = new FitAddon.FitAddon(); + xterm.loadAddon(fitAddon); + xterm.open(terminalEl); + fitAddon.fit(); + + const { master, slave } = openpty(); + xterm.loadAddon(master); + + const mailbox = new SharedArrayBuffer(MB_CTRL_BYTES + MB_DATA_BYTES); + const worker = new Worker("gap-worker.js"); + worker.postMessage({ type: "gap-init", mailbox: mailbox }); + + session = { + xterm: xterm, + fitAddon: fitAddon, + master: master, + slave: slave, + worker: worker, + mbCtrl: new Int32Array(mailbox, 0, 4), + mbData: new Uint8Array(mailbox, MB_CTRL_BYTES), + uploadQueue: [], + senderActive: false, + fetchCount: 0, + started: false, + dead: false, + }; + const s = session; + + // GAP's first terminal output arrives as the worker's first "write" + // tty request. Detect it here (this listener is registered before + // TtyServer.start assigns worker.onmessage, so it runs first) and + // reset the terminal, so the waiting/progress text below is wiped + // and the GAP banner starts on a clean screen. + worker.addEventListener("message", (ev) => { + const d = ev.data; + if (d && d.ttyRequestType === "write" && !s.started) { + s.started = true; + s.xterm.reset(); + loadingEl.style.display = "none"; + } + }); + + session.ttyServer = new TtyServer(slave); + session.ttyServer.start(worker, handleWorkerMessage); + + xterm.write( + "\x1b[2mPlease wait — downloading and starting GAP.\r\n" + + "The first visit can take a few minutes; repeat visits are cached " + + "by your browser and start much faster.\x1b[0m\r\n\r\n"); +} + +function restartSession() { + session.dead = true; + session.worker.terminate(); + session.xterm.dispose(); + for (const entry of fileEntries.values()) { + // The session's filesystem is gone, but our cached bytes are not. + entry.status = "previous"; + } + renderFiles(); + startSession(); +} + +window.addEventListener("resize", () => { + if (session !== null) session.fitAddon.fit(); +}); + +restartBtn.addEventListener("click", () => { + if (session !== null) restartSession(); +}); + +// Escape hatch for stale state: drop the IndexedDB file cache and any +// registered service worker, then reload from the network. (gap-fs.js +// also discards the cache automatically when the site's build-id +// changes; this covers everything else.) +document.getElementById("reset-site").addEventListener("click", async (ev) => { + ev.preventDefault(); + if (session !== null) { + session.dead = true; + session.worker.terminate(); + } + if (navigator.serviceWorker) { + const regs = await navigator.serviceWorker.getRegistrations(); + await Promise.all(regs.map((r) => r.unregister())); + } + await new Promise((resolve) => { + const req = indexedDB.deleteDatabase("/gap_idb_cache"); + req.onsuccess = req.onerror = req.onblocked = resolve; + }); + location.reload(); +}); + +// Without SharedArrayBuffer the worker can't talk to the page +// synchronously; xterm-pty would stall on every read. +if (typeof SharedArrayBuffer === "undefined") { + loadingEl.style.display = "none"; + unsupportedEl.style.display = "block"; +} else { + renderFiles(); + startSession(); +} diff --git a/etc/emscripten/web-template/gap-worker.js b/etc/emscripten/web-template/gap-worker.js index 9b71069a9f..c87864c12b 100755 --- a/etc/emscripten/web-template/gap-worker.js +++ b/etc/emscripten/web-template/gap-worker.js @@ -1,9 +1,216 @@ -importScripts("https://cdn.jsdelivr.net/npm/xterm-pty@0.9.4/workerTools.js"); +// GAP worker: loads the wasm module and wires its TTY to the xterm-pty +// server on the main thread. +// +// Once GAP's main() starts, this thread never returns to the event loop: +// every terminal operation goes through TtyClient, which posts a request +// and then blocks in Atomics.wait until the main thread responds. So +// postMessage TO this worker is never delivered after startup. File +// uploads therefore use a SharedArrayBuffer mailbox (sending side in +// gap-ui.js), drained at the top of every terminal operation — the +// moments this thread is provably awake. postMessage FROM this worker is +// the tty request channel itself, so it always works; downloads are +// pushed to the page that way. + +importScripts("vendor/xterm-pty/workerTools.js"); + +// --------------------------------------------------------------------- +// Upload mailbox (main thread -> worker). +// +// Layout: Int32Array ctrl[4] in bytes [0,16), then a Uint8Array data +// area. ctrl[MB_STATE] is the handshake word: the main thread may only +// write a chunk when it is MB_IDLE (new session) or MB_CONSUMED (next +// chunk), and sets it to MB_READY; the worker consumes the chunk and +// sets MB_CONSUMED, or MB_IDLE after the session-closing END chunk. +// +// A session is one or more files, each sent as a HEADER chunk (JSON +// {name, size}) followed by data chunks with the last flagged FINAL, +// and is terminated by an empty END chunk. The worker consumes a whole +// session in one drainMailbox() call, blocking in Atomics.wait between +// chunks (the main thread never blocks; it polls with Atomics.waitAsync +// or setTimeout). Keep the constants in sync with gap-ui.js. + +const MB_STATE = 0, MB_LEN = 1, MB_FLAGS = 2; +const MB_IDLE = 0, MB_READY = 1, MB_CONSUMED = 2; +const MB_FLAG_HEADER = 1, MB_FLAG_FINAL = 2, MB_FLAG_END = 4; +const MB_CTRL_BYTES = 16; + +let mbCtrl = null; +let mbData = null; + +const USER_DIR = "/home/web_user"; + +function drainMailbox() { + if (mbCtrl === null || Atomics.load(mbCtrl, MB_STATE) !== MB_READY) + return; + + let cur = null; // file in transit: { name, buf, off } + for (;;) { + const flags = mbCtrl[MB_FLAGS]; + const len = mbCtrl[MB_LEN]; + + if (flags & MB_FLAG_END) { + // Session closed; hand the mailbox back to the main thread. + Atomics.store(mbCtrl, MB_STATE, MB_IDLE); + Atomics.notify(mbCtrl, MB_STATE); + return; + } + + if (flags & MB_FLAG_HEADER) { + // slice (not subarray): TextDecoder rejects views backed by + // a SharedArrayBuffer, so decode from a non-shared copy. + const header = JSON.parse( + new TextDecoder().decode(mbData.slice(0, len))); + cur = { + name: header.name, + buf: new Uint8Array(header.size), + off: 0, + }; + } else { + cur.buf.set(mbData.subarray(0, len), cur.off); + cur.off += len; + if (flags & MB_FLAG_FINAL) { + if (cur.off !== cur.buf.length) + throw new Error("gap-worker: upload of " + cur.name + + " ended at " + cur.off + " of " + cur.buf.length + + " bytes"); + FS.writeFile(USER_DIR + "/" + cur.name, cur.buf); + postMessage({ type: "gap-file-uploaded", name: cur.name }); + cur = null; + } + } + + Atomics.store(mbCtrl, MB_STATE, MB_CONSUMED); + Atomics.notify(mbCtrl, MB_STATE); + + // Wait for the main thread to publish the next chunk. It answers + // within its event-loop latency; a long stall means the page side + // died mid-transfer, and hanging GAP forever on that would be + // worse than abandoning the upload loudly. + while (Atomics.load(mbCtrl, MB_STATE) !== MB_READY) { + const r = Atomics.wait(mbCtrl, MB_STATE, MB_CONSUMED, 30000); + if (r === "timed-out") { + console.error("gap-worker: upload stalled; abandoning transfer"); + postMessage({ type: "gap-file-error", + name: cur === null ? null : cur.name, + error: "transfer stalled" }); + Atomics.store(mbCtrl, MB_STATE, MB_IDLE); + return; + } + } + } +} + +// --------------------------------------------------------------------- +// Download push (worker -> main thread). +// +// GAP starts in USER_DIR, which begins empty, so everything under it is +// user-created (or uploaded). Whenever GAP asks for terminal input — +// i.e. output has settled and any files it wrote are complete — walk the +// directory and push new/changed files to the page, which caches the +// bytes so a download click never needs the (possibly blocked) worker. +// +// No throttling: the scan before GAP blocks for input is the LAST chance +// to notice a new file (nothing re-triggers while it is blocked), so a +// time-based throttle would skip exactly the scan that matters. The walk +// is stat-only and the directory is small; files are only read (and +// posted) when size/mtime changed. + +const knownFiles = new Map(); // relative path -> "size:mtime" key + +function scanUserFiles() { + const changedPaths = []; + const seen = new Set(); + (function walk(dir, rel) { + for (const name of FS.readdir(dir)) { + if (name === "." || name === "..") + continue; + const path = dir + "/" + name; + const st = FS.stat(path); + const relPath = rel === "" ? name : rel + "/" + name; + if (FS.isDir(st.mode)) { + walk(path, relPath); + } else if (FS.isFile(st.mode)) { + seen.add(relPath); + const key = st.size + ":" + st.mtime.getTime(); + if (knownFiles.get(relPath) !== key) { + knownFiles.set(relPath, key); + changedPaths.push(relPath); + } + } + } + })(USER_DIR, ""); + + const removed = []; + for (const p of knownFiles.keys()) { + if (!seen.has(p)) { + knownFiles.delete(p); + removed.push(p); + } + } + + if (changedPaths.length === 0 && removed.length === 0) + return; + const changed = changedPaths.map((p) => { + const data = FS.readFile(USER_DIR + "/" + p); // returns a fresh copy + return { path: p, size: data.length, data: data }; + }); + postMessage({ type: "gap-user-files", changed: changed, removed: removed }, + changed.map((f) => f.data.buffer)); +} + +// --------------------------------------------------------------------- +// Startup. Two messages, in order: the page's gap-init (carrying the +// upload mailbox), then the tty SharedArrayBuffer that TtyServer.start() +// posts. Everything else happens inside terminal-operation hooks. onmessage = (msg) => { - // Prepare the Module object BEFORE importing gap.js - self.Module = self.Module || {}; - importScripts("gap-fs.js"); - importScripts("gap.js"); - emscriptenHack(new TtyClient(msg.data)); + if (msg.data && msg.data.type === "gap-init") { + mbCtrl = new Int32Array(msg.data.mailbox, 0, 4); + mbData = new Uint8Array(msg.data.mailbox, MB_CTRL_BYTES); + return; + } + + // Prepare the Module object BEFORE importing gap.js. The FS init in + // gap-fs.js chdirs to USER_DIR, so tell GAP its root explicitly. + self.Module = self.Module || {}; + self.Module.arguments = ["-l", "/"]; + importScripts("gap-fs.js"); + importScripts("gap.js"); + + // Hook every terminal operation: drain pending uploads first (so a + // pasted Read("file.g"); line always finds its file — the paste's + // own keystrokes trigger the drain before GAP sees the newline), and + // scan for new user files when GAP is asking for input. + // + // An exception escaping these hooks would propagate into the wasm + // stack, where emscripten's invoke trampolines can swallow it with + // no console output (observed with TextDecoder throwing on a shared + // buffer) — leaving GAP dead and the failure invisible. Report + // loudly before letting it propagate. + const fileTransferHook = (scan) => { + try { + drainMailbox(); + if (scan) scanUserFiles(); + } catch (e) { + console.error("gap-worker: file transfer hook failed:", e); + postMessage({ type: "gap-file-error", name: null, + error: String(e) }); + throw e; + } + }; + const client = new TtyClient(msg.data); + const hooked = Object.create(client); + hooked.onRead = (length) => { + fileTransferHook(true); + return client.onRead(length); + }; + hooked.onWaitForReadable = (timeout) => { + fileTransferHook(true); + return client.onWaitForReadable(timeout); + }; + hooked.onWrite = (buf) => { + fileTransferHook(false); + return client.onWrite(buf); + }; + emscriptenHack(hooked); }; diff --git a/etc/emscripten/web-template/index.html b/etc/emscripten/web-template/index.html index d5f1bec759..a9e9e992a2 100755 --- a/etc/emscripten/web-template/index.html +++ b/etc/emscripten/web-template/index.html @@ -3,72 +3,321 @@ - GAP in the browser + + GAP in your browser + + - + -
-

GAP in the browser

-

- A WebAssembly build of GAP - running entirely in your browser — useful for trying things out - without installing. Not all packages work, working memory is - capped, and performance is reduced compared to a native build. - For real work, install GAP from - www.gap-system.org. -

+ +
Loading GAP…
+ +
+ This page needs SharedArrayBuffer, which is only + available when the page is served with the headers + Cross-Origin-Opener-Policy: same-origin and + Cross-Origin-Embedder-Policy: require-corp. The + bundled service worker handles this on hosts where you can't set + headers (e.g. GitHub Pages) — its first activation needs a reload, + so if you see this message, try reloading once. For local hosting + use etc/emscripten/serve.py. +
+ +
+
+
+
+ + +
+
What is this? · Licensing

@@ -78,108 +327,49 @@

GAP in the browser

WebAssembly build, served as a static site and run inside a Web Worker; you interact with it through an xterm-pty - terminal in the page below. + terminal.

To get started, try 1+1;, - Factorial(20);, or SymmetricGroup(5);. - The full GAP - manuals apply, with two caveats: some packages won't load - (anything that needs a native compiler or system library), and - anything that wants the local filesystem won't work. + Factorial(20);, or SymmetricGroup(5); — + or use the examples menu. The full + GAP manuals apply, + with some caveats: some packages won't load (anything that needs + a native compiler or system library), working memory is capped, + performance is reduced compared to a native build, and a running + computation cannot be interrupted — the Restart button is the + only way out of a loop.

Files downloaded on first visit are cached in your browser's IndexedDB, so subsequent visits start much faster. Clear the - site data to reset the cache. + site data to reset the cache. Files you create in a session are + not kept across reloads or restarts beyond the panel's + download cache.

GAP is free software, distributed under the GNU General Public License (version 2 or later). See the copyright notice for - the full statement. + the full statement. The Ubuntu fonts are distributed under the + Ubuntu Font Licence.

-
- Loading GAP… The first visit downloads several tens of megabytes; - subsequent visits are cached in your browser and load much faster. -
- -
- This page needs SharedArrayBuffer, which is only - available when the page is served with the headers - Cross-Origin-Opener-Policy: same-origin and - Cross-Origin-Embedder-Policy: require-corp. The - bundled service worker handles this on hosts where you can't set - headers (e.g. GitHub Pages); for local hosting use - etc/emscripten/serve.py. -
- -
-
-
- -