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.
-
A WebAssembly build of GAP running entirely in your browser —
+ no installation. For real work, performance and memory,
+ install GAP.
+
+
+
+
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.
- 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.
-