diff --git a/build.zig b/build.zig index ff762b0e84..3519786ec7 100644 --- a/build.zig +++ b/build.zig @@ -590,7 +590,7 @@ pub fn build(b: *std.Build) !void { if (is_wasm) { nvim_mod.addCSourceFiles(.{ .files = &.{ - "src/wasm_stubs.c", + "src/wasm/wasm_stubs.c", "src/static_ts_registry.c", }, .flags = &flags }); } @@ -637,14 +637,12 @@ pub fn build(b: *std.Build) !void { emcc.addArgs(&.{ b.fmt("--sysroot={s}", .{s}), "-lidbfs.js", - "-sALLOW_MEMORY_GROWTH=1", "--profiling-funcs", "-sEXPORTED_FUNCTIONS=_nvim_main,_malloc,_free", "-Wno-undefined", - "-sEXPORTED_RUNTIME_METHODS=stringToUTF8,lengthBytesUTF8,setValue,getValue,UTF8ToString,ENV,FS,HEAPU8,IDBFS", + "-sEXPORTED_RUNTIME_METHODS=stringToUTF8,lengthBytesUTF8,setValue,getValue,UTF8ToString,ENV,FS,HEAPU8,IDBFS,ccall,cwrap", "-sSTACK_SIZE=8388608", "-sINITIAL_MEMORY=268435456", - "-sMAXIMUM_MEMORY=2147483648", "-sFORCE_FILESYSTEM=1", "-sNODERAWFS=0", "-sERROR_ON_UNDEFINED_SYMBOLS=1", @@ -657,8 +655,8 @@ pub fn build(b: *std.Build) !void { "-flto", "-sSTACK_OVERFLOW_CHECK=2", "-sASSERTIONS=1", - "-sASYNCIFY=1", - "-sASYNCIFY_STACK_SIZE=65536", + "-pthread", + "-sSHARED_MEMORY=1", b.fmt("--preload-file={s}@/runtime", .{merged_path}), }); diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 0fd7f18e27..86b3b6846a 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -220,6 +220,10 @@ BUILD (empty string) to assume that target binaries can run on the host during the build process (e.g. if target is x86 on a x86_64 system, or if emulation set up with binfmt or similar). +• zig build: Nvim can now be built to produce a WebAssembly binary via + "-Dtarget=wasm32-emscripten", which can run inside a web browser. A basic + browser-based demo for running it is also included. Still very + experimental. DEFAULTS diff --git a/src/nvim/CMakeLists.txt b/src/nvim/CMakeLists.txt index 96872ea846..046e1b6593 100644 --- a/src/nvim/CMakeLists.txt +++ b/src/nvim/CMakeLists.txt @@ -445,7 +445,7 @@ list(APPEND UNCRUSTIFY_NVIM_SOURCES ${NVIM_SOURCES} ${NVIM_HEADERS}) list(APPEND UNCRUSTIFY_NVIM_SOURCES ${PROJECT_SOURCE_DIR}/src/tee/tee.c ${PROJECT_SOURCE_DIR}/src/xxd/xxd.c - ${PROJECT_SOURCE_DIR}/src/wasm_stubs.c + ${PROJECT_SOURCE_DIR}/src/wasm/wasm_stubs.c ${PROJECT_SOURCE_DIR}/src/static_ts_registry.c ) diff --git a/src/nvim/event/wstream.c b/src/nvim/event/wstream.c index 6af286ae8f..f471ed3d86 100644 --- a/src/nvim/event/wstream.c +++ b/src/nvim/event/wstream.c @@ -80,8 +80,13 @@ int wstream_write(Stream *stream, WBuffer *buffer) uv_fs_t req; // Synchronous write - err = uv_fs_write(stream->uv.idle.loop, &req, stream->fd, &uvbuf, 1, stream->fpos, NULL); +#ifdef __EMSCRIPTEN__ + // Pass -1 so the write uses the current file position. + err = uv_fs_write(stream->uv.idle.loop, &req, stream->fd, &uvbuf, 1, -1, NULL); +#else + err = uv_fs_write(stream->uv.idle.loop, &req, stream->fd, &uvbuf, 1, stream->fpos, NULL); +#endif uv_fs_req_cleanup(&req); wstream_release_wbuffer(buffer); diff --git a/src/wasm/app.js b/src/wasm/app.js new file mode 100644 index 0000000000..57d16286b5 --- /dev/null +++ b/src/wasm/app.js @@ -0,0 +1,393 @@ +const DEBUG = false; +const statusEl = document.getElementById("status"); +const setStatus = (s) => (statusEl.textContent = s); +const log = (...args) => { + if (!DEBUG) return; + if (logEl) { + logEl.textContent += + args + .map((a) => (typeof a === "string" ? a : JSON.stringify(a))) + .join(" ") + "\n"; + } + console.log(...args); +}; + +// UiState below is ported from (github.com/MuNeNiCK/nvim-wasm) +// but, extended with win_viewport tracking. +class UiState { + constructor(cols, rows) { + this.defaultWidth = Math.max(1, cols || 80); + this.defaultHeight = Math.max(1, rows || 24); + this.defaultGrid = 1; + this.activeGrid = this.defaultGrid; + this.grids = new Map(); + this.grids.set( + this.defaultGrid, + this.#createGrid(this.defaultWidth, this.defaultHeight), + ); + this.cursor = { grid: this.defaultGrid, row: 0, col: 0 }; + this.mode = "-"; + this.modeIdx = 0; + this.cursorStyleEnabled = false; + this.modeInfo = []; + this.cursorHlId = 0; + this.hls = new Map(); + this.hls.set(0, { foreground: null, background: null, reverse: false }); + this.windows = new Map(); + } + + resize(gridId, width, height) { + const grid = this.#ensureGrid(gridId); + const w = Math.max(1, width || 0); + const h = Math.max(1, height || 0); + grid.width = w; + grid.height = h; + grid.cells = Array.from({ length: h }, () => this.#blankRow(w)); + } + + clear(gridId) { + const grid = this.#ensureGrid(gridId); + grid.cells = Array.from({ length: grid.height }, () => + this.#blankRow(grid.width), + ); + } + + destroy(gridId) { + this.grids.delete(gridId); + this.windows.delete(gridId); + if (this.activeGrid === gridId) this.activeGrid = this.defaultGrid; + } + + line(gridId, row, colStart, cells) { + const grid = this.#ensureGrid(gridId); + const rowIdx = row || 0; + if (rowIdx < 0 || rowIdx >= grid.height) return; + const rowCells = + grid.cells[rowIdx] || (grid.cells[rowIdx] = this.#blankRow(grid.width)); + let col = colStart || 0; + let currentHl = 0; + for (const cell of cells) { + const text = cell[0]; + const hlId = + cell.length > 1 && cell[1] !== undefined ? cell[1] : currentHl; + currentHl = hlId; + const repeat = cell[2] || 1; + for (let r = 0; r < repeat && col < grid.width; r += 1) { + rowCells[col] = { ch: text || " ", hl: hlId }; + col += 1; + } + } + } + + scroll(gridId, top, bot, left, right, rows) { + const grid = this.#ensureGrid(gridId); + const height = bot - top; + const width = right - left; + const slice = []; + for (let i = 0; i < height; i += 1) { + const row = grid.cells[top + i] || this.#blankRow(grid.width); + slice.push(row.slice(left, right)); + } + if (rows > 0) { + for (let i = 0; i < height - rows; i += 1) { + grid.cells[top + i].splice(left, width, ...slice[i + rows]); + } + for (let i = height - rows; i < height; i += 1) { + grid.cells[top + i].splice(left, width, ...this.#blankRow(width)); + } + } else if (rows < 0) { + for (let i = height - 1; i >= -rows; i -= 1) { + grid.cells[top + i].splice(left, width, ...slice[i + rows]); + } + for (let i = 0; i < -rows; i += 1) { + grid.cells[top + i].splice(left, width, ...this.#blankRow(width)); + } + } + } + + setCursor(gridId, row, col) { + this.activeGrid = gridId; + this.#ensureGrid(gridId); + this.cursor = { grid: gridId, row: row || 0, col: col || 0 }; + } + + setMode(mode, modeIdx) { + this.mode = mode || "-"; + if (Number.isInteger(modeIdx)) this.modeIdx = modeIdx; + this.#updateCursorHl(); + } + + setModeInfo(cursorStyleEnabled, modeInfo) { + this.cursorStyleEnabled = Boolean(cursorStyleEnabled); + this.modeInfo = Array.isArray(modeInfo) ? modeInfo : []; + this.#updateCursorHl(); + } + + defineHl(id, rgbAttr = {}) { + this.hls.set(id, { + foreground: this.#toHex(rgbAttr.foreground), + background: this.#toHex(rgbAttr.background), + reverse: Boolean(rgbAttr.reverse), + }); + } + + setWindowViewport(gridId, winHandle, topline, botline) { + this.windows.set(gridId, { winHandle, topline, botline }); + } + + snapshot() { + const grid = + this.grids.get(this.activeGrid) || this.grids.get(this.defaultGrid); + if (!grid) + return { + cells: [[{ ch: " ", hl: 0 }]], + cursor: { row: 0, col: 0 }, + mode: this.mode, + hls: this.hls, + }; + const row = Math.min(Math.max(this.cursor.row, 0), grid.height - 1); + const col = Math.min(Math.max(this.cursor.col, 0), grid.width - 1); + return { + cells: grid.cells.map((r) => + r.map((c) => ({ ch: c?.ch ?? " ", hl: c?.hl ?? 0 })), + ), + cursor: { row, col }, + cursorHlId: this.cursorHlId, + mode: this.mode, + hls: this.hls, + }; + } + + #ensureGrid(gridId) { + if (!this.grids.has(gridId)) + this.grids.set( + gridId, + this.#createGrid(this.defaultWidth, this.defaultHeight), + ); + return this.grids.get(gridId); + } + #createGrid(width, height) { + return { + width, + height, + cells: Array.from({ length: height }, () => this.#blankRow(width)), + }; + } + #blankRow(width) { + return Array.from({ length: width }, () => ({ ch: " ", hl: 0 })); + } + #updateCursorHl() { + if (!this.cursorStyleEnabled) { + this.cursorHlId = 0; + return; + } + const info = this.modeInfo[this.modeIdx] || null; + this.cursorHlId = info?.attr_id ?? 0; + } + #toHex(value) { + if (value === undefined || value === null) return null; + return `#${(value >>> 0).toString(16).padStart(6, "0").slice(-6)}`; + } +} + +function handleRedrawEvents(uiState, gridEl, modeEl, events) { + for (const ev of events) { + const [name, ...entries] = ev; + switch (name) { + case "grid_resize": + for (const [grid, w, h] of entries) uiState.resize(grid, w, h); + break; + case "grid_clear": + for (const [grid] of entries) uiState.clear(grid); + break; + case "grid_destroy": + for (const [grid] of entries) uiState.destroy(grid); + break; + case "grid_line": + for (const [grid, row, col, cells] of entries) + uiState.line(grid, row, col, cells); + break; + case "grid_scroll": + for (const [grid, top, bot, left, right, rows] of entries) + uiState.scroll(grid, top, bot, left, right, rows); + break; + case "grid_cursor_goto": + for (const [grid, row, col] of entries) + uiState.setCursor(grid, row, col); + break; + case "win_viewport": + for (const [grid, win, topline, botline] of entries) + uiState.setWindowViewport(grid, win, topline, botline); + break; + case "mode_info_set": + for (const [enabled, modeInfo] of entries) + uiState.setModeInfo(enabled, modeInfo); + break; + case "mode_change": + for (const [mode, idx] of entries) uiState.setMode(mode, idx); + break; + case "hl_attr_define": + for (const [id, rgbAttr] of entries) uiState.defineHl(id, rgbAttr); + break; + case "flush": + paintGrid(uiState, gridEl, modeEl); + break; + default: + break; // mouse on/off, busy start/stop, etc. + } + } +} + +function paintGrid(uiState, gridEl, modeEl) { + const { cells, cursor, cursorHlId, mode, hls } = uiState.snapshot(); + let html = ""; + for (let r = 0; r < cells.length; r += 1) { + for (let c = 0; c < cells[r].length; c += 1) { + const cell = cells[r][c]; + const isCursor = r === cursor.row && c === cursor.col; + const hl = hls.get(isCursor ? cursorHlId : cell.hl) || {}; + let style = ""; + if (hl.reverse) { + style = `color:${hl.background || "#050912"};background:${hl.foreground || "#dfe5f1"}`; + } else { + if (hl.foreground) style += `color:${hl.foreground};`; + if (hl.background) style += `background:${hl.background};`; + } + const ch = escapeHtml(cell.ch || " "); + if (isCursor) { + html += `${ch}`; + } else if (style) { + html += `${ch}`; + } else { + html += ch; + } + } + if (r < cells.length - 1) html += "\n"; + } + gridEl.innerHTML = html || " "; + if (modeEl) modeEl.textContent = `mode: ${mode}`; +} + +function escapeHtml(ch) { + if (ch === "&") return "&"; + if (ch === "<") return "<"; + if (ch === ">") return ">"; + return ch; +} + +function translateKey(ev) { + const isCtrl = ev.ctrlKey || ev.metaKey; + const isAlt = ev.altKey; + const named = { + Backspace: "", + Enter: "", + Escape: "", + Tab: "", + ArrowUp: "", + ArrowDown: "", + ArrowLeft: "", + ArrowRight: "", + Delete: "", + Home: "", + End: "", + PageUp: "", + PageDown: "", + }; + if (named[ev.key]) return named[ev.key]; + if (ev.key.length === 1) { + let char = ev.shiftKey ? ev.key : ev.key.toLowerCase(); + if (char === "<") char = ""; + if (!isCtrl && !isAlt) return char; + let mod = ""; + if (isCtrl) mod += "C-"; + if (isAlt) mod += "A-"; + return `<${mod}${char}>`; + } + return null; +} + +function main() { + const transport = new WorkerTransport("nvim-worker.js", { + cols: 120, + rows: 40, + }); + // internal FS.write/stderr trace line the worker emits. + transport.onStatus((text) => { + log("[status]", text); + if (/^(FS\.write|WRITE HOOK|\[stderr\])/.test(text)) return; + setStatus(text); + }); + + const nvim = new RpcClient(transport); + window.nvim = nvim; // for console debugging + + const uiState = new UiState(120, 40); + const gridEl = document.getElementById("grid"); + const modeEl = document.getElementById("mode"); + + let apiInfoPromise = null; + function ensureHandleTypesRegistered() { + if (!apiInfoPromise) { + apiInfoPromise = (async () => { + const result = await nvim.request("nvim_get_api_info", []); + log("RESPONSE nvim_get_api_info ->", result); + + const [channelId, metadata] = result; + log("types metadata ->", metadata.types); + + registerNvimHandleTypes(metadata.types); + setStatus("got nvim_get_api_info response, handle types registered"); + + return result; + })(); + } + return apiInfoPromise; + } + + // Auto-attach + visible failure status + transport.onReady(async () => { + const maxAttempts = 5; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + await ensureHandleTypesRegistered(); + await nvim.request("nvim_ui_attach", [ + 110, + 40, + { rgb: true, ext_linegrid: true }, + ]); + setStatus("UI attached"); + return; + } catch (e) { + console.error(`auto-attach attempt ${attempt} failed`, e); + log("auto-attach failed ->", e && e.message ? e.message : String(e)); + if (attempt === maxAttempts) { + setStatus("auto-attach failed after retries, see console (F12)"); + } else { + await new Promise((r) => setTimeout(r, 300 * attempt)); + } + } + } + }); + + nvim.on("redraw", (params) => + handleRedrawEvents(uiState, gridEl, modeEl, params), + ); + + gridEl.addEventListener("click", () => gridEl.focus()); + gridEl.addEventListener("keydown", (ev) => { + const keys = translateKey(ev); + if (!keys) return; + ev.preventDefault(); + nvim + .request("nvim_input", [keys]) + .catch((e) => console.error("nvim_input failed", e)); + }); +} + +if (!crossOriginIsolated) { + setStatus( + "NOT cross-origin isolated -- SharedArrayBuffer unavailable. Serve with COOP/COEP headers.", + ); +} else { + main(); +} diff --git a/src/wasm/index.html b/src/wasm/index.html new file mode 100644 index 0000000000..db24ceb17e --- /dev/null +++ b/src/wasm/index.html @@ -0,0 +1,53 @@ + + + + + nvim.wasm RPC client + + + + + + +
+ +
+ booting… +
+ + + + + + + + diff --git a/src/wasm/msgpack.js b/src/wasm/msgpack.js new file mode 100644 index 0000000000..a275a1a612 --- /dev/null +++ b/src/wasm/msgpack.js @@ -0,0 +1,62 @@ +if (typeof msgpackr === 'undefined') { + throw new Error( + 'msgpackr library not loaded. ' + + 'Check that the script tag for msgpackr is present and loads correctly.' + ); +} + +// Buffer/Window/Tabpage handles come back as msgpack EXT values. +// - Type code: not fixed across builds, so read it from +// nvim_get_api_info()'s types metadata at runtime instead of +// hardcoding it. +// - Payload: itself a variable length msgpack integer, +// so it needs a real unpack call, not a fixed width DataView read. +function registerNvimHandleTypes(types) { + for (const [name, info] of Object.entries(types)) { + msgpackr.addExtension({ + type: info.id, + unpack(payload) { + return { __nvimType: name, id: msgpackr.unpack(payload) }; + }, + pack(value) { + return msgpackr.pack(value.id); + }, + }); + console.log(`[msgpack] registered Nvim EXT type ${info.id} -> ${name}`); + } +} + +const MsgpackCodec = { + encode(value) { + const encoded = msgpackr.pack(value); + const prefix = Array.from(encoded.slice(0, Math.min(encoded.length, 8))) + .map(b => b.toString(16).padStart(2, '0')) + .join(' '); + console.log(`[MsgpackCodec.encode] ${encoded.length} bytes, prefix: ${prefix}`); + return encoded; + }, + + decodeMultiple(bytes) { + const messages = []; + let consumed = 0; + + try { + msgpackr.unpackMultiple(bytes, (value, start, end) => { + messages.push(value); + consumed = end; + }); + consumed = bytes.length; + } catch (e) { + if (!isIncompleteDataError(e)) { + console.error('[MsgpackCodec.decodeMultiple] fatal decode error', e, 'consumed so far:', consumed); + throw e; + } + } + + return { messages, consumed }; + }, +}; + +function isIncompleteDataError(e) { + return /unexpected end|incomplete|out of bounds|out of range/i.test(e?.message || ''); +} diff --git a/src/wasm/msgpackrpc.js b/src/wasm/msgpackrpc.js new file mode 100644 index 0000000000..11d467d9cb --- /dev/null +++ b/src/wasm/msgpackrpc.js @@ -0,0 +1,34 @@ +// msgpack-rpc message shapes: +// request: [0, msgid, method, params] +// response: [1, msgid, error, result] +// notification: [2, method, params] + +const Protocol = { + encodeRequest(msgid, method, params) { + return MsgpackCodec.encode([0, msgid, method, params]); + }, + + encodeNotification(method, params) { + return MsgpackCodec.encode([2, method, params]); + }, + + // classifies a decoded msgpack-rpc array into a tagged object. + // throws if msg doesn't look like a valid rpc message. + parseMessage(msg) { + if (!Array.isArray(msg)) throw new Error("rpc message is not an array"); + const [type, ...rest] = msg; + if (type === 0) { + const [msgid, method, params] = rest; + return { kind: "request", msgid, method, params }; + } + if (type === 1) { + const [msgid, error, result] = rest; + return { kind: "response", msgid, error, result }; + } + if (type === 2) { + const [method, params] = rest; + return { kind: "notification", method, params }; + } + throw new Error("unknown rpc message type: " + type); + }, +}; diff --git a/src/wasm/nvim-worker.js b/src/wasm/nvim-worker.js new file mode 100644 index 0000000000..f277bf3576 --- /dev/null +++ b/src/wasm/nvim-worker.js @@ -0,0 +1,597 @@ +const CAP = 1 << 16; + +let state = null; +let ringData = null; + +let moduleRef = null; + +let totalBytesRead = 0; +const consumedBytes = []; +const STATUS_MSG_CAP = 2000; +let statusMsgCount = 0; +let statusCapNoticeSent = false; + +function safeStatus(text) { + statusMsgCount++; + if (statusMsgCount > STATUS_MSG_CAP) { + if (!statusCapNoticeSent) { + statusCapNoticeSent = true; + postMessage({ + type: "status", + text: + "STATUS LOGGING SUPPRESSED after " + + STATUS_MSG_CAP + + " messages -- likely a busy/runaway loop. " + + "See perFdWriteCount below for which fd is spinning.", + }); + } + return; + } + postMessage({ type: "status", text: text }); +} + +const perFdWriteCount = {}; +function bumpFdWriteCount(fd) { + perFdWriteCount[fd] = (perFdWriteCount[fd] || 0) + 1; +} + +/* File descriptor monitored by libuv for stdin. Verify this value + against the startup FD dump, as it may differ across environments. */ + +const STDIN_FD = 9; + +/* + SharedArrayBuffer layout: + offset 0: + Int32Array: + [0] head + [1] tail + [2] closed + + offset 12: + Uint8Array data +*/ + +function unreadCount() { + const head = Atomics.load(state, 0); + const tail = Atomics.load(state, 1); + return (head - tail + CAP) % CAP; +} + +// Synchronizes the JS ring buffer with the shared atomics used by +// uv__io_poll to allow libuv to detect newly available input. +function markReadable() { + safeStatus( + "MARK READABLE called moduleRef=" + !!moduleRef + " STDIN_FD=" + STDIN_FD, + ); + if (!moduleRef) return; + const n = unreadCount(); + safeStatus("MARK READABLE n=" + n); + try { + moduleRef.ccall( + "uv_browser_set_readable", + null, + ["number", "number"], + [STDIN_FD, n], + ); + safeStatus("MARK READABLE ccall succeeded"); + } catch (e) { + safeStatus("MARK READABLE FAILED: " + e); + } +} + +// Debug helper +function dumpFD(fd) { + const s = moduleRef.FS.streams[fd]; + + safeStatus(" FD " + fd); + + if (!s) { + safeStatus("NO STREAM"); + return; + } + + safeStatus("path=" + s.path + " fd=" + s.fd + " tty=" + !!s.tty); + + safeStatus( + "same stdout ops=" + (s.stream_ops === moduleRef.FS.streams[1].stream_ops), + ); + + try { + const st = moduleRef.FS.fstat(fd); + + safeStatus( + "mode=" + + st.mode.toString(8) + + " isChrdev=" + + moduleRef.FS.isChrdev(st.mode), + ); + } catch (e) { + safeStatus("fstat failed: " + e); + } +} + +function popByte() { + const head = Atomics.load(state, 0); + const tail = Atomics.load(state, 1); + + if (head === tail) return -1; + + const b = ringData[tail]; + + Atomics.store(state, 1, (tail + 1) % CAP); + + totalBytesRead++; + consumedBytes.push(b); + + console.log("POP BYTE", b.toString(16)); + + return b; +} + +// Called when new bytes are pushed into the ring buffer from the main +// thread. Advances head and notifies both the JS side Atomics.wait() consumers and the C-side +// futex consumers (uv__io_poll). +function pushBytes(bytes) { + if (!bytes || !bytes.length) return; + + let head = Atomics.load(state, 0); + + for (let i = 0; i < bytes.length; i++) { + const tail = Atomics.load(state, 1); + const nextHead = (head + 1) % CAP; + + /* If the ring buffer is full, drop new data instead of overwriting + unread bytes. Overwriting would corrupt the input stream. If this + happens often, increase CAP or add backpressure on the producer. */ + if (nextHead === tail) { + console.warn("stdin ring buffer full, dropping byte"); + break; + } + + ringData[head] = bytes[i]; + head = nextHead; + } + + Atomics.store(state, 0, head); + + // wake anything doing an Atomics.wait() on head, if anything still does + Atomics.notify(state, 0); + + // tell the C side (uv__io_poll) that fd 9 now has data + markReadable(); +} + +function flushAll() { + if (stdoutBuffer.length) { + postMessage({ + type: "stdout", + bytes: stdoutBuffer, + }); + stdoutBuffer = []; + } + + if (stderrBuffer.length) { + postMessage({ + type: "stderr", + bytes: stderrBuffer, + }); + stderrBuffer = []; + } +} + +let stdoutBuffer = []; +let stderrBuffer = []; + +function writeStderr(c) { + stderrBuffer.push(c); + + if (stderrBuffer.length > 50) { + postMessage({ + type: "stderr", + bytes: stderrBuffer, + }); + + stderrBuffer = []; + } +} + +function makeArgv(M, args) { + const ptrs = args.map((s) => { + const len = M.lengthBytesUTF8(s) + 1; + + const p = M._malloc(len); + + M.stringToUTF8(s, p, len); + + return p; + }); + + const argv = M._malloc((ptrs.length + 1) * 4); + + ptrs.forEach((p, i) => M.setValue(argv + i * 4, p, "*")); + + M.setValue(argv + ptrs.length * 4, 0, "*"); + + return { + argc: ptrs.length, + argv, + }; +} + +self.onerror = (e) => { + safeStatus("WORKER ERROR " + e.message); +}; + +self.onunhandledrejection = (e) => { + const r = e.reason; + const info = + r && typeof r === "object" + ? `name=${r.name} message=${r.message} status=${r.status}` + : String(r); + safeStatus("REJECTION " + info); +}; + +/* Hook the shared TTY get_char callback instead of stream_ops.read. + All duplicated file descriptors share the same TTY object, making + this work for stdin and any dup()ed descriptors. + Return one byte when available, undefined when no data is ready + (causing Emscripten to report EAGAIN), or null on EOF. Blocking is + handled by uv__io_poll so this callback always remains non-blocking. + */ +function installStdoutWrite(m, stdoutStream) { + if ( + !stdoutStream.stream_ops || + typeof stdoutStream.stream_ops.write !== "function" + ) { + throw new Error( + "stdout stream has no stream_ops.write: " + stdoutStream.path, + ); + } + // The worker owns the transport, so forwarding the chunk via postMessage + // completes the write. + // Skip the original stream_ops.write to avoid duplicate output. + stdoutStream.stream_ops.write = function ( + stream, + buffer, + offset, + length, + position, + ) { + safeStatus( + "WRITE HOOK fd=" + stream.fd + " path=" + stream.path + " len=" + length, + ); + + try { + console.log("HOOK: entered"); + /* Forward the entire chunk as a single message to preserve msgpack-RPC + framing. Then copy it into a Uint8Array to ensure the transmitted bytes + remain correct regardless of the source buffer's signedness. */ + const chunk = new Uint8Array(length); + for (let i = 0; i < length; i++) { + chunk[i] = buffer[offset + i]; + } + console.log( + "HOOK: chunk built", + chunk.length, + chunk[0], + chunk[1], + chunk[2], + chunk[3], + ); + console.log( + "stdout chunk", + length, + chunk[0], + chunk[1], + chunk[2], + chunk[3], + ); + console.log("HOOK: BEFORE postMessage"); + + postMessage({ + type: "stdout", + bytes: chunk, + }); + console.log("HOOK: AFTER postMessage"); + } catch (e) { + console.error("HOOK: postMessage failed", e); + + safeStatus("STDOUT HOOK ERROR: " + e + " stack=" + e.stack); + } + return length; + }; +} + +function installStdinGetChar(m, stdinStream) { + if (!stdinStream.tty || !stdinStream.tty.ops) { + throw new Error( + "stdin stream has no .tty.ops -- stream is not TTY-backed: " + + stdinStream.path, + ); + } + + stdinStream.tty.ops.get_char = function (tty) { + const b = popByte(); + + if (b !== -1) { + markReadable(); // reflect the new unread count + return b; + } + + if (Atomics.load(state, 2) === 1) { + console.log("stdin closed"); + return null; // real EOF + } + + return undefined; // no data yet -> Emscripten turns this into EAGAIN + }; +} + +self.onmessage = async (ev) => { + const msg = ev.data; + + if (msg.type === "init") { + state = new Int32Array(msg.sab, 0, 3); // [head, tail, closed] + ringData = new Uint8Array(msg.sab, 12, CAP); // offset moved from 8 → 12 + safeStatus("loading wasm..."); + importScripts("../../zig-out/bin/nvim.js"); + + const m = await createNvim({ + locateFile: (p) => + p.endsWith(".data") + ? "../../zig-out/bin/nvim.data" + : "../../zig-out/bin/" + p, + noInitialRun: true, + interactive: false, + + stdout: () => {}, + + stderr: (c) => writeStderr(c), + tty: false, + print: (t) => safeStatus("[print] " + t), + printErr: (t) => safeStatus("[printErr] " + t), + preRun: [ + (m) => { + m.ENV.TERM = "xterm-256color"; + m.ENV.HOME = "/home/user"; + m.ENV.VIMRUNTIME = "/runtime"; + m.ENV.NVIM_LOG_FILE = "/home/user/nvim.log"; + }, + ], + }); + + moduleRef = m; + + const statePtr = m.ccall( + "uv_browser_get_shared_state_ptr", + "number", + [], + [], + ); + postMessage({ + type: "shared-info", + memory: m.HEAPU8.buffer, + statePtr: statePtr, + }); + postMessage({ type: "ready" }); + const stdinStream = m.FS.streams[0]; + + console.log("stdin stream", stdinStream.path); + + installStdinGetChar(m, stdinStream); + const stdoutStream = m.FS.streams[1]; + + console.log("stdoutStream ", stdoutStream); + console.log(stdoutStream.node); + console.log(stdoutStream.node.rdev); + console.log(stdoutStream.tty); + console.log(stdoutStream.stream_ops); + + console.log("stdout stream", stdoutStream.path); + installStdoutWrite(m, stdoutStream); + console.log("PATCHED STDOUT WRITE =", m.FS.streams[1].stream_ops.write); + await new Promise((res, rej) => + m.FS.syncfs(true, (e) => (e ? rej(e) : res())), + ); + try { + m.FS.mkdir("/home/user/.config/nvim"); + } catch (e) {} + try { + m.FS.mkdir("/home/user/.local/share/nvim"); + } catch (e) {} + try { + m.FS.mkdir("/runtime/parser"); + } catch (e) {} + [ + "lua", + "c", + "vim", + "vimdoc", + "query", + "markdown", + "markdown_inline", + ].forEach((p) => { + try { + m.FS.writeFile(`/runtime/parser/${p}.so`, ""); + } catch (e) {} + }); + + const patchedOps = stdinStream.tty && stdinStream.tty.ops; + // Dump every open fd and confirm which fd libuv is actually polling + // on and whether it shares the patched tty record. If STDIN_FD + // above doesn't match what shows up here as the dup'd stdin fd then + // fix the constant at the top of this file. + for (let i = 0; i < 16; i++) { + const s = m.FS.streams[i]; + console.log("FD", i, s && s.path, s && s.tty && s.tty.ops === patchedOps); + } + + function sanitizeAttr(attr) { + attr.dev = attr.dev ?? 1; + attr.ino = attr.ino ?? 1; + attr.mode = attr.mode ?? 0o666; + attr.nlink = attr.nlink ?? 1; + attr.uid = attr.uid ?? 0; + attr.gid = attr.gid ?? 0; + attr.rdev = attr.rdev ?? 0; + attr.size = + typeof attr.size === "number" && !isNaN(attr.size) ? attr.size : 0; + attr.blksize = attr.blksize ?? 4096; + attr.blocks = attr.blocks ?? 0; + const validDate = (d) => d instanceof Date && !isNaN(d.getTime()); + attr.atime = validDate(attr.atime) ? attr.atime : new Date(0); + attr.mtime = validDate(attr.mtime) ? attr.mtime : new Date(0); + attr.ctime = validDate(attr.ctime) ? attr.ctime : new Date(0); + return attr; + } + + function isStdinLike(path) { + const p = path || ""; + return ( + p.startsWith("pipe[") || p.includes("my_stdin") || p === "/dev/stdin" + ); + } + + ["fstat", "stat", "lstat"].forEach((name) => { + if (typeof m.FS[name] !== "function") return; + const orig = m.FS[name].bind(m.FS); + m.FS[name] = function (...args) { + const attr = orig(...args); + const path = + name === "fstat" + ? m.FS.streams[args[0]] && m.FS.streams[args[0]].path + : args[0]; + if (isStdinLike(path)) { + attr.mode = 0o010666; + } + return sanitizeAttr(attr); + }; + }); + + const origFSWrite = m.FS.write; + m.FS.write = function (stream, buffer, offset, length, position, canOwn) { + console.log("FS.write-----"); + console.log("fd =", stream.fd); + console.log("path =", stream.path); + + console.log("stream_ops =", stream.stream_ops); + + console.log("write fn =", stream.stream_ops && stream.stream_ops.write); + + console.log("stdout write fn =", m.FS.streams[1].stream_ops.write); + + console.log( + "same write?", + stream.stream_ops && + stream.stream_ops.write === m.FS.streams[1].stream_ops.write, + ); + bumpFdWriteCount(stream.fd); + safeStatus( + "FS.write fd=" + + stream.fd + + " path=" + + stream.path + + " len=" + + length + + " (fd" + + stream.fd + + " call#" + + perFdWriteCount[stream.fd] + + ")", + ); + return origFSWrite.call( + this, + stream, + buffer, + offset, + length, + position, + canOwn, + ); + }; + + const { argc, argv } = makeArgv(m, ["nvim", "--embed", "--clean"]); + + let ret; + + try { + safeStatus("Starting Neovim..."); + + console.log("CALLING NVIM MAIN"); + + const nvimPromise = m._nvim_main(argc, argv); + + console.log("returned:", nvimPromise); + console.log("instanceof Promise:", nvimPromise instanceof Promise); + console.log("constructor:", nvimPromise?.constructor?.name); + + setTimeout(() => { + dumpFD(1); + dumpFD(9); + dumpFD(10); + }, 1000); + + ret = await nvimPromise; + + console.log("NVIM EXITED"); + const unread = unreadCount(); + safeStatus( + `_nvim_main RETURNED ret=${ret}, unread bytes still in buffer=${unread}`, + ); + } catch (e) { + flushAll(); + safeStatus("EXCEPTION: " + e.message + "\nSTACK:\n" + e.stack); + throw e; + } + + flushAll(); + + const hexStr = consumedBytes + .slice(0, 50) + .map((x) => x.toString(16).padStart(2, "0")) + .join(" "); + const remaining = + consumedBytes.length > 50 + ? `... (${consumedBytes.length - 50} more bytes)` + : ""; + safeStatus( + `EXIT CODE ${ret}. Read ${totalBytesRead} bytes: ${hexStr}${remaining}`, + ); + } + + if (msg.type === "stdin") { + pushBytes(msg.bytes); + } + + if (msg.type === "persist") { + if (!moduleRef) { + postMessage({ type: "persisted", error: "module not ready" }); + return; + } + moduleRef.FS.syncfs(false, (e) => + postMessage({ type: "persisted", error: e ? String(e) : null }), + ); + } + if (msg.type === "readable-hint") { + markReadable(); + } + + if (msg.type === "dump-counters") { + safeStatus( + "perFdWriteCount=" + + JSON.stringify(perFdWriteCount) + + " statusMsgCount=" + + statusMsgCount + + " suppressed=" + + statusCapNoticeSent, + ); + } + if (msg.type === "shutdown") { + if (state) { + Atomics.store(state, 2, 1); + Atomics.notify(state, 0); // wake up anything Atomics.wait()ing + markReadable(); // also wake the C side futex so uv__io_poll rescans and sees the close + } + } +}; diff --git a/src/wasm/rpc.js b/src/wasm/rpc.js new file mode 100644 index 0000000000..ecaf47942c --- /dev/null +++ b/src/wasm/rpc.js @@ -0,0 +1,245 @@ +// TODO: Add a fallback when SharedArrayBuffer is unavailable. +// Transport: owns the worker and SharedArrayBuffer +class WorkerTransport { + constructor(workerPath, { cols = 80, rows = 24, cap = 1 << 16 } = {}) { + this.CAP = cap; + this.sab = new SharedArrayBuffer(12 + this.CAP); + this.state = new Int32Array(this.sab, 0, 3); // [head, tail, closed] + this.ringData = new Uint8Array(this.sab, 12, this.CAP); // offset 12 + + this._bytesHandlers = []; + this._statusHandlers = []; + this._readyHandlers = []; + + this.worker = new Worker(workerPath); + this.worker.onmessage = (ev) => { + console.log("[main] worker message", ev.data.type, ev.data); + this._onWorkerMessage(ev.data); + }; + this.worker.onerror = (e) => this._emitStatus("worker error: " + e.message); + this.worker.postMessage({ type: "init", sab: this.sab, cols, rows }); + } + + shutdown() { + Atomics.store(this.state, 2, 1); + Atomics.notify(this.state, 0); + this.worker.postMessage({ type: "shutdown" }); + } + + _onWorkerMessage(msg) { + console.log("[main] _onWorkerMessage got", msg.type); + + if (msg.type === "stdout") { + console.log(`[main] stdout bytes:`, msg.bytes); + this._bytesHandlers.forEach((h) => h(msg.bytes)); + } else if (msg.type === "stderr") { + const text = new TextDecoder().decode(new Uint8Array(msg.bytes)); + this._emitStatus("[stderr] " + text); + } else if (msg.type === "status") { + this._emitStatus(msg.text); + } else if (msg.type === "shared-info") { + this.sharedState = new Int32Array( + msg.memory, + msg.statePtr, + 1 + 256 + 256, + ); + console.log("[main] shared-info received, direct futex wake enabled"); + } else if (msg.type === "ready") { + console.log("[main] worker signaled ready, RPC channel should be live"); + this._readyHandlers.forEach((h) => h()); + } + } + + _emitStatus(text) { + console.log("[main] status:", text); + this._statusHandlers.forEach((h) => h(text)); + } + + send(bytes) { + console.log("[main] Send: writing", bytes.length, "bytes"); + const hexStr = Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(" "); + console.log(`[main] Send: ${bytes.length} bytes: ${hexStr}`); + + let lastHead = 0; + for (let i = 0; i < bytes.length; i++) { + const b = bytes[i]; + const head = Atomics.load(this.state, 0); + const tail = Atomics.load(this.state, 1); + const next = (head + 1) % this.CAP; + if (next === tail) { + console.error("[main] Send: ring buffer full"); + return false; + } + this.ringData[head] = b; + Atomics.store(this.state, 0, next); + lastHead = next; + } + + if (this.sharedState) { + // Write straight into the C-side shared struct, then wake the futex. + // This only works if the worker is already inside + // emscripten_futex_wait() at the moment call notify() if it is + // not waiting yet, the notify does nothing. Atomics.notify() does not + // queue up a wake for some future wait() call as it only wakes threads + // that are waiting right now. + const STDIN_FD = 9; + const tail = Atomics.load(this.state, 1); + const n = (lastHead - tail + this.CAP) % this.CAP; + Atomics.store(this.sharedState, 1 + STDIN_FD, n); // readable[STDIN_FD] = n + Atomics.add(this.sharedState, 0, 1); // generation++ + Atomics.notify(this.sharedState, 0, 1); // wake the futex_wait, if any + console.log("[main] Send Done, direct notified futex, n=", n); + } + // There's a real startup race here: if this send() happens before the + // worker's event loop has reached its first Atomics.wait() (e.g. + // auto-attach fires the instant ready arrives), the direct notify + // above lands on nobody and is silently wasted. And if the C side poll + // loop only reacts to notify events instead of rechecking the shared + // value on every scan that missed wake is gone for good and the + // request just sits in the ring buffer forever. + // + // But until the worker actually starts blocking, it is still free to + // process its own message queue so this postMessage gets handled + // right away, routing through the worker's own markReadable() -> + // uv_browser_set_readable ccall path. That makes send() correct + // whether or not nvim has reached its poll loop yet instead of + // depending on lucky notify timing. + this.worker.postMessage({ type: "readable-hint" }); + console.log( + "[main] SEND DONE, also sent readable-hint as belt-and-suspenders", + ); + + return true; + } + + onBytes(cb) { + this._bytesHandlers.push(cb); + } + onStatus(cb) { + this._statusHandlers.push(cb); + } + onReady(cb) { + this._readyHandlers.push(cb); + } + persist() { + this.worker.postMessage({ type: "persist" }); + } +} + +class RpcClient { + constructor(transport) { + this.transport = transport; + this.nextMsgId = 1; + this.pending = new Map(); + this.notificationHandlers = new Map(); + + this._buffer = new Uint8Array(0); + + transport.onBytes((bytes) => this._handleBytes(bytes)); + } + + _handleBytes(newBytes) { + console.log( + "[RpcClient] _handleBytes", + newBytes.length, + newBytes.slice(0, 32), + ); + + const combined = new Uint8Array(this._buffer.length + newBytes.length); + combined.set(this._buffer, 0); + combined.set(newBytes, this._buffer.length); + this._buffer = combined; + + const { messages, consumed } = MsgpackCodec.decodeMultiple(this._buffer); + + for (const msg of messages) { + console.log("[RpcClient] raw decoded:", JSON.stringify(msg)); + this._dispatch(msg); + } + + this._buffer = this._buffer.slice(consumed); + } + + _dispatch(rawMsg) { + console.log("[RpcClient] dispatching:", rawMsg); + + let msg; + try { + msg = Protocol.parseMessage(rawMsg); + } catch (e) { + console.error("[RpcClient] parse failed", rawMsg, e); + return; + } + + console.log("[RpcClient] parsed:", msg); + + if (msg.kind === "response") { + const p = this.pending.get(msg.msgid); + if (!p) { + console.warn("[RpcClient] unknown msgid", msg.msgid, "pending:", [ + ...this.pending.keys(), + ]); + return; + } + this.pending.delete(msg.msgid); + clearTimeout(p.timeoutId); + console.log("[RpcClient] resolved msgid", msg.msgid); + if (msg.error) { + p.reject(new Error(msg.error)); + } else { + p.resolve(msg.result); + } + } else if (msg.kind === "notification") { + console.log("[RpcClient] notification:", msg.method); + const handlers = this.notificationHandlers.get(msg.method) || []; + handlers.forEach((h) => h(msg.params)); + } else if (msg.kind === "request") { + console.warn("[RpcClient] unhandled request", msg); + } + } + + request(method, params = []) { + const msgid = this.nextMsgId++; + const bytes = Protocol.encodeRequest(msgid, method, params); + console.log( + `[RpcClient] sending request ${msgid} (${method}), ${bytes.length} bytes`, + ); + + return new Promise((resolve, reject) => { + this.pending.set(msgid, { + resolve, + reject, + method, + timestamp: Date.now(), + }); + + const timeoutId = setTimeout(() => { + if (this.pending.has(msgid)) { + this.pending.delete(msgid); + reject(new Error(`Request ${msgid} (${method}) timed out after 30s`)); + } + }, 30000); + + this.pending.get(msgid).timeoutId = timeoutId; + + this.transport.send(bytes); + }); + } + + notify(method, params = []) { + const bytes = Protocol.encodeNotification(method, params); + console.log( + `[RpcClient] sending notification ${method}, ${bytes.length} bytes`, + ); + this.transport.send(bytes); + } + + on(method, handler) { + if (!this.notificationHandlers.has(method)) { + this.notificationHandlers.set(method, []); + } + this.notificationHandlers.get(method).push(handler); + } +} diff --git a/src/wasm/serve.py b/src/wasm/serve.py new file mode 100644 index 0000000000..bff568c96b --- /dev/null +++ b/src/wasm/serve.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 + +""" +Local server for the WASM demo. +Usage: python3 src/wasm/serve.py +""" + +import http.server +import socketserver +from pathlib import Path +from functools import partial + +PORT = 8002 + +ROOT = Path(__file__).resolve().parents[2] + +class Handler(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") + super().end_headers() + +if __name__ == "__main__": + handler = partial(Handler, directory=str(ROOT)) + + with socketserver.TCPServer(("", PORT), handler) as httpd: + print(f"Serving {ROOT} on http://localhost:{PORT}") + httpd.serve_forever() diff --git a/src/wasm/style.css b/src/wasm/style.css new file mode 100644 index 0000000000..640b80682a --- /dev/null +++ b/src/wasm/style.css @@ -0,0 +1,16 @@ +body { + font-family: monospace; + background: #1e1e1e; + color: #ddd; + padding: 10px; + margin: 0; +} +#bar { + margin-bottom: 10px; +} +#bar button { + margin-right: 8px; +} +#status { + color: #9c9; +} diff --git a/src/wasm/wasm_stubs.c b/src/wasm/wasm_stubs.c new file mode 100644 index 0000000000..a83bf6d66a --- /dev/null +++ b/src/wasm/wasm_stubs.c @@ -0,0 +1,314 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +void uv__io_cb(uv_loop_t *loop, uv__io_t *w, unsigned events); + +// System info stubs + +uint64_t uv_get_free_memory(void) +{ + return 8 * 1024 * 1024; +} +uint64_t uv_get_total_memory(void) +{ + return 256 * 1024 * 1024; +} +uint64_t uv_get_available_memory(void) +{ + return uv_get_free_memory(); +} +uint64_t uv_get_constrained_memory(void) +{ + return uv_get_total_memory(); +} + +void uv_loadavg(double avg[3]) +{ + avg[0] = 0.0; avg[1] = 0.0; avg[2] = 0.0; +} + +int uv_uptime(double *uptime) +{ + if (!uptime) { + return UV_EINVAL; + } + *uptime = emscripten_get_now() / 1000.0; + return 0; +} + +int uv_resident_set_memory(size_t *rss) +{ + if (!rss) { + return UV_EINVAL; + } + *rss = uv_get_total_memory() / 2; // fake value + return 0; +} + +int uv_exepath(char *buffer, size_t *size) +{ + if (!buffer || !size) { + return UV_EINVAL; + } + const char *exepath = "/nvim.wasm"; + size_t len = strlen(exepath); + if (*size <= len) { + *size = len + 1; + return UV_ENOBUFS; + } + memcpy(buffer, exepath, len); + buffer[len] = '\0'; + *size = len; + return 0; +} + +// Browsers have no native CPU information. So return a single virtual CPU +int uv_cpu_info(uv_cpu_info_t * *cpu_infos, int *count) +{ + if (!cpu_infos || !count) { + return UV_EINVAL; + } + *cpu_infos = (uv_cpu_info_t *)malloc(sizeof(uv_cpu_info_t)); + if (!*cpu_infos) { + return UV_ENOMEM; + } + + uv_cpu_info_t *cpu = *cpu_infos; + cpu->model = "WebAssembly Virtual CPU"; + cpu->speed = 0; + cpu->cpu_times.user = 0; cpu->cpu_times.nice = 0; cpu->cpu_times.sys = 0; + cpu->cpu_times.idle = 0; cpu->cpu_times.irq = 0; + *count = 1; + return 0; +} + +// Browsers do not expose host network interfaces. Report only loopback. +int uv_interface_addresses(uv_interface_address_t * *addresses, int *count) +{ + if (!addresses || !count) { + return UV_EINVAL; + } + *addresses = (uv_interface_address_t *)malloc(sizeof(uv_interface_address_t)); + if (!*addresses) { + return UV_ENOMEM; + } + + uv_interface_address_t *addr = *addresses; + memset(addr, 0, sizeof(uv_interface_address_t)); + addr->name = strdup("lo"); + if (!addr->name) { + free(addr); return UV_ENOMEM; + } + addr->is_internal = 1; + uv_ip4_addr("127.0.0.1", 0, (struct sockaddr_in *)&addr->address); + uv_ip4_addr("255.0.0.0", 0, (struct sockaddr_in *)&addr->netmask); + *count = 1; + return 0; +} + +#define UV_BROWSER_MAX_FD 256 + +typedef struct { + _Atomic uint32_t generation; // woken on any fd state change + _Atomic int32_t readable[UV_BROWSER_MAX_FD]; + _Atomic int32_t writable[UV_BROWSER_MAX_FD]; +} uv_browser_shared_state_t; + +static uv_browser_shared_state_t g_shared_state; + +EMSCRIPTEN_KEEPALIVE +void *uv_browser_get_shared_state_ptr(void) +{ + return (void *)&g_shared_state; +} + +EMSCRIPTEN_KEEPALIVE +void uv_browser_set_readable(int fd, int32_t nbytes) +{ + if (fd < 0 || fd >= UV_BROWSER_MAX_FD) { + return; + } + atomic_store(&g_shared_state.readable[fd], nbytes); + atomic_fetch_add(&g_shared_state.generation, 1); + emscripten_futex_wake(&g_shared_state.generation, 1); +} + +EMSCRIPTEN_KEEPALIVE +void uv_browser_set_writable(int fd, int32_t nbytes) +{ + if (fd < 0 || fd >= UV_BROWSER_MAX_FD) { + return; + } + atomic_store(&g_shared_state.writable[fd], nbytes); + atomic_fetch_add(&g_shared_state.generation, 1); + emscripten_futex_wake(&g_shared_state.generation, 1); +} + +static int uv_browser_scan_ready(uv_loop_t *loop, unsigned *out_idx, int *out_revents) +{ + unsigned i; + uv__io_t *w; + int revents; + + for (i = 0; i < loop->nwatchers && i < UV_BROWSER_MAX_FD; i++) { + w = loop->watchers[i]; + if (w == NULL || w->pevents == 0) { + continue; + } + + revents = 0; + if ((w->pevents & POLLIN) && atomic_load(&g_shared_state.readable[i]) > 0) { + revents |= POLLIN; + } + if ((w->pevents & POLLOUT) && atomic_load(&g_shared_state.writable[i]) > 0) { + revents |= POLLOUT; + } + + if (revents) { + *out_idx = i; + *out_revents = revents; + return 1; + } + } + return 0; +} + +void uv__io_poll(uv_loop_t *loop, int timeout) +{ + double deadline; + double remaining; + uint32_t g0; + unsigned idx; + int revents; + int have_signal; + uv__io_t *w; + + deadline = (timeout < 0) ? -1.0 : (emscripten_get_now() + (double)timeout); + have_signal = 0; + + while (true) { + /* Any state change that lands between this load and the scan below is still caught by the scan + itself. Any change that lands after the scan will bump generation past g0, so the futex_wait call + falls through immediately instead of sleeping through it. This closes the narrow window where a notify + could otherwise land between "nothing is ready" and "waiting". */ + + g0 = atomic_load(&g_shared_state.generation); + + if (uv_browser_scan_ready(loop, &idx, &revents)) { + have_signal = 1; + break; + } + + if (deadline >= 0.0) { + remaining = deadline - emscripten_get_now(); + if (remaining <= 0.0) { + break; + } + } else { + remaining = 60000.0; + } + + int rc = emscripten_futex_wait(&g_shared_state.generation, g0, remaining); + } + + uv_update_time(loop); + + if (!have_signal) { + return; + } + + for (idx = 0; idx < loop->nwatchers && idx < UV_BROWSER_MAX_FD; idx++) { + w = loop->watchers[idx]; + if (w == NULL || w->pevents == 0) { + continue; + } + + revents = 0; + if ((w->pevents & POLLIN) && atomic_load(&g_shared_state.readable[idx]) > 0) { + revents |= POLLIN; + } + if ((w->pevents & POLLOUT) && atomic_load(&g_shared_state.writable[idx]) > 0) { + revents |= POLLOUT; + } + + revents &= w->pevents | POLLERR | POLLHUP; + + if (revents == 0) { + continue; + } + + uv__io_cb(loop, w, revents); + } +} + +int uv__platform_loop_init(uv_loop_t *loop) +{ + if (!loop) { + return UV_EINVAL; + } + loop->backend_fd = -1; + return 0; +} + +void uv__platform_loop_delete(uv_loop_t *loop) +{ + (void)loop; +} + +void uv__platform_invalidate_fd(uv_loop_t *loop, int fd) +{ + if (fd < 0 || fd >= UV_BROWSER_MAX_FD) { + return; + } + atomic_store(&g_shared_state.readable[fd], 0); + atomic_store(&g_shared_state.writable[fd], 0); +} + +int uv__io_check_fd(uv_loop_t *loop, int fd) +{ + (void)loop; (void)fd; + return 0; +} + +int uv__io_fork(uv_loop_t *loop) +{ + (void)loop; + return 0; +} + +// pthread shims + +int pthread_getname_np(pthread_t thread, char *name, size_t len) +{ + (void)thread; + if (!name || len < 1) { + return EINVAL; + } + strncpy(name, "nvim-main", len - 1); + name[len - 1] = '\0'; + return 0; +} + +int pthread_setname_np(pthread_t thread, const char *name) +{ + (void)thread; (void)name; + return 0; +} + +int pthread_setschedparam(pthread_t thread, int policy, const struct sched_param *param) +{ + (void)thread; (void)policy; (void)param; + return 0; +} diff --git a/src/wasm_stubs.c b/src/wasm_stubs.c deleted file mode 100644 index 94c121be96..0000000000 --- a/src/wasm_stubs.c +++ /dev/null @@ -1,181 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -uint64_t uv_get_free_memory(void) -{ - return 8 * 1024 * 1024; -} -uint64_t uv_get_total_memory(void) -{ - return 256 * 1024 * 1024; -} -uint64_t uv_get_available_memory(void) -{ - return uv_get_free_memory(); -} -uint64_t uv_get_constrained_memory(void) -{ - return uv_get_total_memory(); -} - -// Report an idle virtual system -void uv_loadavg(double avg[3]) -{ - avg[0] = 0.0; avg[1] = 0.0; avg[2] = 0.0; -} - -int uv_uptime(double *uptime) -{ - if (!uptime) { - return UV_EINVAL; - } - *uptime = emscripten_get_now() / 1000.0; - return 0; -} - -int uv_resident_set_memory(size_t *rss) -{ - if (!rss) { - return UV_EINVAL; - } - *rss = uv_get_total_memory() / 2; - return 0; -} - -int uv_exepath(char *buffer, size_t *size) -{ - if (!buffer || !size) { - return UV_EINVAL; - } - const char *exepath = "/nvim.wasm"; - size_t len = strlen(exepath); - if (*size <= len) { - *size = len + 1; - return UV_ENOBUFS; - } - memcpy(buffer, exepath, len); - buffer[len] = '\0'; - *size = len; - return 0; -} - -int uv__io_fork(uv_loop_t *loop) -{ - (void)loop; - return 0; -} - -int uv_cpu_info(uv_cpu_info_t * *cpu_infos, int *count) -{ - if (!cpu_infos || !count) { - return UV_EINVAL; - } - *cpu_infos = (uv_cpu_info_t *)malloc(sizeof(uv_cpu_info_t)); - if (!*cpu_infos) { - return UV_ENOMEM; - } - - uv_cpu_info_t *cpu = *cpu_infos; - cpu->model = "WebAssembly Virtual CPU"; - cpu->speed = 0; - cpu->cpu_times.user = 0; cpu->cpu_times.nice = 0; cpu->cpu_times.sys = 0; - cpu->cpu_times.idle = 0; cpu->cpu_times.irq = 0; - *count = 1; - return 0; -} - -int uv_interface_addresses(uv_interface_address_t * *addresses, int *count) -{ - if (!addresses || !count) { - return UV_EINVAL; - } - *addresses = (uv_interface_address_t *)malloc(sizeof(uv_interface_address_t)); - if (!*addresses) { - return UV_ENOMEM; - } - - uv_interface_address_t *addr = *addresses; - memset(addr, 0, sizeof(uv_interface_address_t)); - addr->name = strdup("lo"); - if (!addr->name) { - free(addr); return UV_ENOMEM; - } - addr->is_internal = 1; - uv_ip4_addr("127.0.0.1", 0, (struct sockaddr_in *)&addr->address); - uv_ip4_addr("255.0.0.0", 0, (struct sockaddr_in *)&addr->netmask); - *count = 1; - return 0; -} - -void uv__platform_invalidate_fd(uv_loop_t *loop, int fd) -{ - (void)loop; (void)fd; -} -int uv__io_check_fd(uv_loop_t *loop, void *w) -{ - (void)loop; (void)w; return 0; -} - -// Replaces the kernel poll() call -void uv__io_poll(uv_loop_t *loop, int timeout) -{ - if (timeout > 0) { - emscripten_sleep(timeout); - uv_update_time(loop); - emscripten_sleep(10); - uv_update_time(loop); - } else if (timeout == 0) { - emscripten_sleep(0); - } -} - -int uv__platform_loop_init(uv_loop_t *loop) -{ - if (!loop) { - return UV_EINVAL; - } - loop->backend_fd = -1; - return 0; -} - -void uv__platform_loop_delete(uv_loop_t *loop) -{ - (void)loop; -} - -// Fakes thread names when nvim tracks or debugs its internal processes -int pthread_getname_np(pthread_t thread, char *name, size_t len) -{ - (void)thread; - if (!name || len < 1) { - return EINVAL; - } - strncpy(name, "nvim-main", len - 1); - name[len - 1] = '\0'; - return 0; -} - -int pthread_setname_np(pthread_t thread, const char *name) -{ - (void)thread; (void)name; return 0; -} -int pthread_setschedparam(pthread_t thread, int policy, const struct sched_param *param) -{ - (void)thread; (void)policy; (void)param; return 0; -} -int sched_get_priority_max(int policy) -{ - (void)policy; return 1; -} -int sched_get_priority_min(int policy) -{ - (void)policy; return 1; -}