From 9cabba671d99ca63d6d28e8aa6a9f270b51038b4 Mon Sep 17 00:00:00 2001 From: rawan10101 Date: Thu, 16 Jul 2026 21:17:54 +0300 Subject: [PATCH 1/6] feat(wasm-emcc): add browser support for Neovim --- src/wasm/app.js | 60 +++++++++++++++++++++++++++++++++++++++++ src/wasm/index.html | 25 +++++++++++++++++ src/wasm/msgpack.js | 0 src/wasm/nvim-worker.js | 0 src/wasm/protocol.js | 0 src/wasm/rpc.js | 0 src/wasm/serve.py | 16 +++++++++++ src/wasm/style.css | 5 ++++ 8 files changed, 106 insertions(+) create mode 100644 src/wasm/app.js create mode 100644 src/wasm/index.html create mode 100644 src/wasm/msgpack.js create mode 100644 src/wasm/nvim-worker.js create mode 100644 src/wasm/protocol.js create mode 100644 src/wasm/rpc.js create mode 100644 src/wasm/serve.py create mode 100644 src/wasm/style.css diff --git a/src/wasm/app.js b/src/wasm/app.js new file mode 100644 index 0000000000..48b4f61e25 --- /dev/null +++ b/src/wasm/app.js @@ -0,0 +1,60 @@ +const statusEl = document.getElementById('status'); +const logEl = document.getElementById('log'); +const setStatus = s => statusEl.textContent = s; +const log = (...args) => { + logEl.textContent += args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ') + '\n'; + console.log(...args); +}; + +if (!crossOriginIsolated) { + setStatus('NOT cross-origin isolated -- SharedArrayBuffer unavailable. Serve with COOP/COEP headers.'); +} else { + main(); +} + +function main() { + const transport = new WorkerTransport('nvim-worker.js', { cols: 80, rows: 24 }); + transport.onStatus(text => { setStatus(text); log('[status]', text); }); + + const nvim = new RpcClient(transport); + setTimeout(async () => { + console.log("Auto Request"); + + try { + const r = await nvim.request("nvim_get_api_info", []); + console.log(r); + } catch (e) { + console.error(e); + } +}, 10000); + + nvim.on('redraw', params => log('Notification redraw', params)); + + document.getElementById('apiInfoBtn').addEventListener('click', async () => { + setStatus('sending nvim_get_api_info...'); + try { + const result = await nvim.request('nvim_get_api_info', []); + log('RESPONSE nvim_get_api_info ->', result); + setStatus('got nvim_get_api_info response'); + } catch (e) { + log('ERROR nvim_get_api_info ->', e); + setStatus('nvim_get_api_info failed, see log'); + } + }); + + document.getElementById('attachBtn').addEventListener('click', async () => { + setStatus('sending nvim_ui_attach...'); + try { + const result = await nvim.request('nvim_ui_attach', [80, 24, { rgb: true, ext_linegrid: true }]); + log('RESPONSE nvim_ui_attach ->', result); + setStatus('UI attached finally. Watching for redraw notifications...'); + } catch (e) { + log('ERROR nvim_ui_attach ->', e); + setStatus('nvim_ui_attach failed, see log'); + } + }); + + document.getElementById('persistBtn').addEventListener('click', () => { + transport.persist(); + }); +} diff --git a/src/wasm/index.html b/src/wasm/index.html new file mode 100644 index 0000000000..44b38347a4 --- /dev/null +++ b/src/wasm/index.html @@ -0,0 +1,25 @@ + + + + +nvim.wasm RPC client + + + +
+ + + + booting… +
+
+ + + + + + + + + + diff --git a/src/wasm/msgpack.js b/src/wasm/msgpack.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/wasm/nvim-worker.js b/src/wasm/nvim-worker.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/wasm/protocol.js b/src/wasm/protocol.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/wasm/rpc.js b/src/wasm/rpc.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/wasm/serve.py b/src/wasm/serve.py new file mode 100644 index 0000000000..753935f9e7 --- /dev/null +++ b/src/wasm/serve.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +import http.server +import socketserver + +PORT = 8001 + +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__": + with socketserver.TCPServer(("", PORT), Handler) as httpd: + print(f"Serving with COOP/COEP 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..d27d30a75a --- /dev/null +++ b/src/wasm/style.css @@ -0,0 +1,5 @@ +body { font-family: monospace; background: #1e1e1e; color: #ddd; padding: 10px; margin: 0; } +#bar { margin-bottom: 10px; } +#bar button { margin-right: 8px; } +#status { color: #9c9; } +#log { white-space: pre-wrap; background: #111; padding: 10px; border-radius: 4px; } From 2b412f098e1d6e82e5646265f73779acb1ba6a68 Mon Sep 17 00:00:00 2001 From: rawan10101 Date: Tue, 21 Jul 2026 01:57:57 +0300 Subject: [PATCH 2/6] feat: initial WASM RPC implementation (RPC handshake incomplete) --- src/wasm/msgpack.js | 25 ++++ src/wasm/nvim-worker.js | 303 ++++++++++++++++++++++++++++++++++++++++ src/wasm/protocol.js | 34 +++++ src/wasm/rpc.js | 190 +++++++++++++++++++++++++ 4 files changed, 552 insertions(+) diff --git a/src/wasm/msgpack.js b/src/wasm/msgpack.js index e69de29bb2..52291cb566 100644 --- a/src/wasm/msgpack.js +++ b/src/wasm/msgpack.js @@ -0,0 +1,25 @@ +// Check that the library is actually loaded. +if (typeof MessagePack === 'undefined') { + throw new Error( + 'MessagePack library not loaded. ' + + 'Check that the CDN script tag for @msgpack/msgpack is present and loads correctly.' + ); +} + +const MsgpackCodec = { + encode(value) { + const encoded = MessagePack.encode(value); + + // Debug: log the encoded bytes (first few bytes) to verify. + 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; + }, + + decodeMulti(bytes) { + return [...MessagePack.decodeMulti(bytes)]; // ✅ correct global + }, +}; diff --git a/src/wasm/nvim-worker.js b/src/wasm/nvim-worker.js index e69de29bb2..07b9cbbcf9 100644 --- a/src/wasm/nvim-worker.js +++ b/src/wasm/nvim-worker.js @@ -0,0 +1,303 @@ +const CAP = 1 << 16; // must match the main thread's ring buffer size +let state, ringData; // state is now Int32Array(3): [head, tail, closed] + +let stdinPollCount = 0; +let totalBytesRead = 0; +const consumedBytes = []; + +let pendingPollCallbacks = []; +function wakePendingPolls() { + const cbs = pendingPollCallbacks; + pendingPollCallbacks = []; + for (const cb of cbs) cb(65); +} +let lastCheckedTail = -1; + +function checkForNewDataAndWake() { + if (!state) return; // not initialized yet + const head = Atomics.load(state, 0); + const tail = Atomics.load(state, 1); + if (head !== tail && pendingPollCallbacks.length > 0) { + wakePendingPolls(); + } +} + +setInterval(checkForNewDataAndWake, 20); // check every 20ms + +function popNonBlocking() { + const head = Atomics.load(state, 0); + const tail = Atomics.load(state, 1); + if (head !== tail) { + const b = ringData[tail]; + Atomics.store(state, 1, (tail + 1) % CAP); + totalBytesRead++; + consumedBytes.push(b); + return b; + } + return -1; +} + +// blocks the Worker thread until a byte is available or shutdown fires. +function popBlocking() { + while (true) { //producer-consumer + const head = Atomics.load(state, 0); + const tail = Atomics.load(state, 1); + + if (head !== tail) { + const b = ringData[tail]; + Atomics.store(state, 1, (tail + 1) % CAP); + totalBytesRead++; + consumedBytes.push(b); + return b; + } + + if (Atomics.load(state, 2) === 1) { + return -1; + } + + // Blocks here until Atomics.notify(state, 0) fires, or 1s elapses + Atomics.wait(state, 0, head, 1000); + } +} + +let stdoutBuf = []; +let stderrBuf = []; + +function flushStdout() { + if (stdoutBuf.length) { + postMessage({ type: 'stdout', bytes: stdoutBuf }); + stdoutBuf = []; + } +} + +function flushStderr() { + if (stderrBuf.length) { + postMessage({ type: 'stderr', bytes: stderrBuf }); + stderrBuf = []; + } +} + +function flushAll() { + flushStdout(); + flushStderr(); +} + +function writeStdout(c) { + stdoutBuf.push(c); + flushStdout(); +} + +function writeStderr(c) { + stderrBuf.push(c); + if (stderrBuf.length > 80) { + flushStderr(); + } +} + +function makeArgv(M, args) { + const ptrs = args.map(s => { + const len = M.lengthBytesUTF8(s) + 1, 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 }; +} + +let moduleRef = null; + +self.onerror = (e) => { + postMessage({ type: 'status', text: 'WORKER ERROR: ' + e.message }); +}; + +self.onunhandledrejection = (e) => { + postMessage({ type: 'status', text: 'WORKER REJECTION: ' + e }); +}; + +function makeBridgeReadOps(origOps, label) { + const newOps = Object.assign({}, origOps); + newOps.read = function (stream, buffer, offset, length, position) { + let n = 0; + while (n < length) { + const b = popNonBlocking(); + if (b === -1) break; + buffer[offset + n] = b; + n++; + } + if (n === 0) { + console.log(`[stream_ops:${label}] no data available, returning 0 (temporary — not correct long-term)`); + return 0; // TEMP: fake EOF + } + console.log(`[stream_ops:${label}] read ${n} bytes`); + return n; + }; +newOps.poll = function (stream, timeout, notifyCallback) { + const head = Atomics.load(state, 0); + const tail = Atomics.load(state, 1); + + if (head !== tail) { + return 65; + } + + if (notifyCallback) { + pendingPollCallbacks.push(notifyCallback); + } + + return 0; +}; + const origGetattr = origOps.getattr; + newOps.getattr = function (stream) { + const attr = origGetattr ? origGetattr.call(this, stream) : { mode: 0 }; + attr.mode = (attr.mode & ~0xF000) | 0x1000; + return attr; + }; + return newOps; +} + +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 +setInterval(checkForNewDataAndWake, 20); + postMessage({ type: 'status', text: '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, + stdin: () => { + console.log('[stdin callback] fired'); + const b = popNonBlocking(); + return b === -1 ? null : b; + }, + stdout: c => writeStdout(c), + stderr: c => writeStderr(c), + print: t => postMessage({ type: 'status', text: '[print] ' + t }), + printErr: t => postMessage({ type: 'status', text: '[printErr] ' + t }), + preRun: [m => { + m.ENV.TERM = "xterm-256color"; + m.ENV.HOME = "/home/user"; + m.ENV.VIMRUNTIME = "/runtime"; + m.ENV.COLUMNS = String(msg.cols); + m.ENV.LINES = String(msg.rows); + try { m.FS.mkdir('/tmp'); } catch (e) {} + m.FS.mkdir('/home/user'); + m.FS.mkdir('/home/user/.config'); + m.FS.mkdir('/home/user/.local'); + m.FS.mkdir('/home/user/.local/share'); + m.FS.mount(m.IDBFS, {}, '/home/user/.config'); + m.FS.mount(m.IDBFS, {}, '/home/user/.local/share'); + }], + }); + + 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) {} + }); + + moduleRef = m; + + 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 origCreateStream = m.FS.createStream; + m.FS.createStream = function (stream, fd) { + const s = origCreateStream.call(this, stream, fd); + const p = s.path || ''; + const looksLikeStdin = p.startsWith('pipe[') || p.includes('my_stdin') || p === '/dev/stdin'; + if (looksLikeStdin && !s._patched) { + s.stream_ops = makeBridgeReadOps(s.stream_ops, 'createStream:' + p); + s._patched = true; + } + return s; + }; + + const origFSRead = m.FS.read; + m.FS.read = function (stream, buffer, offset, length, position) { + if (stream.path && stream.path.startsWith('pipe[') && !stream._patched) { + stream.stream_ops = makeBridgeReadOps(stream.stream_ops, 'fallback:' + stream.path); + stream._patched = true; + } + return origFSRead.call(this, stream, buffer, offset, length, position); + }; + + const origFSWrite = m.FS.write; + m.FS.write = function (stream, buffer, offset, length, position, canOwn) { + return origFSWrite.call(this, stream, buffer, offset, length, position, canOwn); + }; + + const { argc, argv } = makeArgv(m, ["nvim", "--embed"]); + let ret; + try { + postMessage({ type: 'status', text: 'Starting Neovim...' }); + ret = await m._nvim_main(argc, argv); + const headAtExit = Atomics.load(state, 0); + const tailAtExit = Atomics.load(state, 1); + const unread = (headAtExit - tailAtExit + CAP) % CAP; + postMessage({ type: 'status', text: `_nvim_main RETURNED ret=${ret}, unread bytes still in buffer=${unread} (head=${headAtExit}, tail=${tailAtExit})` }); + + } catch (e) { + flushAll(); + postMessage({ type: 'status', text: 'EXCEPTION: ' + e.message }); + 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)` : ''; + postMessage({ type: 'status', text: `EXIT CODE ${ret}. Read ${totalBytesRead} bytes: ${hexStr}${remaining}` }); + } + 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 === 'shutdown') { + if (state) { + Atomics.store(state, 2, 1); + Atomics.notify(state, 0); // wake up any pending popBlocking() + } + } +}; diff --git a/src/wasm/protocol.js b/src/wasm/protocol.js index e69de29bb2..b8f1fede9f 100644 --- a/src/wasm/protocol.js +++ b/src/wasm/protocol.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/rpc.js b/src/wasm/rpc.js index e69de29bb2..2aa220f434 100644 --- a/src/wasm/rpc.js +++ b/src/wasm/rpc.js @@ -0,0 +1,190 @@ +// Transport: owns the worker + SharedArrayBuffer ring buffer +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.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:', 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); + } + } + + _emitStatus(text) { + console.log('[main] status:', text); + this._statusHandlers.forEach(h => h(text)); + } + + send(bytes) { + console.log("[main] SEND: writing", bytes.length, "bytes"); + +// This ensures all data is in the buffer before Nvim wakes up +const hexStr = Array.from(bytes) + .map(b => b.toString(16).padStart(2, '0')) + .join(' '); + console.log(`[main] Send: ${bytes.length} bytes: ${hexStr}`); + + 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); + // wait until all bytes are written + } + + // notify once after all bytes are in the buffer + Atomics.notify(this.state, 0); + + console.log("[main] SEND DONE, notified worker"); + return true; + } + + onBytes(cb) { this._bytesHandlers.push(cb); } + onStatus(cb) { this._statusHandlers.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); + this._decodedMessages = []; + + transport.onBytes(bytes => this._handleBytes(bytes)); + } + + _handleBytes(newBytes) { + const combined = new Uint8Array(this._buffer.length + newBytes.length); + combined.set(this._buffer, 0); + combined.set(newBytes, this._buffer.length); + this._buffer = combined; + + const decoder = new MessagePack.Decoder(); + let offset = 0; + + while (offset < this._buffer.length) { + let msg; + try { + // decodes exactly one value starting at `offset` tells us how much it consumed + decoder.setBuffer(this._buffer.subarray(offset)); + msg = decoder.decode(); + } catch (e) { + break; // incomplete trailing message — stop, wait for more bytes + } + offset += decoder.bytesConsumed ?? decoder.pos; // depends on library version + this._dispatch(msg); + } + + // only drop what was actually consumed and keep any incomplete tail + this._buffer = this._buffer.slice(offset); + } + + _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); + 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 batches all bytes before notifying + 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); + } +} From 5ca90d1185592e3b02c0e0d06ea1bbcbc8c9f565 Mon Sep 17 00:00:00 2001 From: rawan10101 Date: Sun, 26 Jul 2026 03:52:38 +0300 Subject: [PATCH 3/6] build(wasm): use pthreads and correct uv_fs_write offset handling --- build.zig | 8 +++----- src/nvim/event/wstream.c | 10 +++++++++- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/build.zig b/build.zig index 97db2c97e1..6aefb190da 100644 --- a/build.zig +++ b/build.zig @@ -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/src/nvim/event/wstream.c b/src/nvim/event/wstream.c index 6af286ae8f..47d709dcd4 100644 --- a/src/nvim/event/wstream.c +++ b/src/nvim/event/wstream.c @@ -80,15 +80,23 @@ 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); assert(stream->write_cb == NULL); +#ifndef __EMSCRIPTEN__ stream->fpos += MAX(req.result, 0); +#endif return req.result > 0 ? 0 : err != 0 ? err : UV_UNKNOWN; } From e0dd164fb45b9af025fff020650094f608dfa238 Mon Sep 17 00:00:00 2001 From: rawan10101 Date: Mon, 27 Jul 2026 02:42:41 +0300 Subject: [PATCH 4/6] build(wasm): update wasm_stubs with browser libuv polling support Replace the placeholder polling implementation with a browser-backed libuv polling backend and add the required platform compatibility stubs for the WebAssembly build. --- src/wasm_stubs.c | 215 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 180 insertions(+), 35 deletions(-) diff --git a/src/wasm_stubs.c b/src/wasm_stubs.c index 94c121be96..830121d23e 100644 --- a/src/wasm_stubs.c +++ b/src/wasm_stubs.c @@ -1,5 +1,8 @@ #include +#include +#include #include +#include #include #include #include @@ -9,6 +12,23 @@ #include #include +#ifndef POLLIN +# define POLLIN 0x001 +#endif +#ifndef POLLOUT +# define POLLOUT 0x004 +#endif +#ifndef POLLERR +# define POLLERR 0x008 +#endif +#ifndef POLLHUP +# define POLLHUP 0x010 +#endif + +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; @@ -26,7 +46,6 @@ 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; @@ -46,7 +65,7 @@ int uv_resident_set_memory(size_t *rss) if (!rss) { return UV_EINVAL; } - *rss = uv_get_total_memory() / 2; + *rss = uv_get_total_memory() / 2; // fake value return 0; } @@ -67,13 +86,8 @@ int uv_exepath(char *buffer, size_t *size) 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) +// 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; @@ -92,7 +106,8 @@ int uv_cpu_info(uv_cpu_info_t * *cpu_infos, int *count) return 0; } -int uv_interface_addresses(uv_interface_address_t * *addresses, int *count) +// 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; @@ -115,25 +130,138 @@ int uv_interface_addresses(uv_interface_address_t * *addresses, int *count) return 0; } -void uv__platform_invalidate_fd(uv_loop_t *loop, int fd) +#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) { - (void)loop; (void)fd; -} -int uv__io_check_fd(uv_loop_t *loop, void *w) -{ - (void)loop; (void)w; return 0; + 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; } -// 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); + 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); } } @@ -151,7 +279,29 @@ void uv__platform_loop_delete(uv_loop_t *loop) (void)loop; } -// Fakes thread names when nvim tracks or debugs its internal processes +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; @@ -165,17 +315,12 @@ int pthread_getname_np(pthread_t thread, char *name, size_t len) int pthread_setname_np(pthread_t thread, const char *name) { - (void)thread; (void)name; return 0; + (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; + (void)thread; (void)policy; (void)param; + return 0; } From 17d1ed53ffe81ee50835ac2c6de8f5180bdfeb01 Mon Sep 17 00:00:00 2001 From: rawan10101 Date: Tue, 28 Jul 2026 15:32:17 +0300 Subject: [PATCH 5/6] feat(wasm): implement initial web demo Introduce an initial browser-based Neovim demo with automatic startup and UI integration. The demo is functional but still requires persistence, additional testing, and further stabilization. Acknowledgment: UiState in app.js is based on the implementation from github.com/MuNeNiCK/nvim-wasm, with additional modifications and updates --- build.zig | 2 +- src/nvim/CMakeLists.txt | 2 +- src/nvim/event/wstream.c | 7 +- src/wasm/app.js | 417 +++++++++++++-- src/wasm/index.html | 70 ++- src/wasm/msgpack.js | 57 +- src/wasm/{protocol.js => msgpackrpc.js} | 12 +- src/wasm/nvim-worker.js | 684 +++++++++++++++++------- src/wasm/rpc.js | 198 ++++--- src/wasm/serve.py | 18 +- src/wasm/style.css | 21 +- src/{ => wasm}/wasm_stubs.c | 18 +- 12 files changed, 1130 insertions(+), 376 deletions(-) rename src/wasm/{protocol.js => msgpackrpc.js} (66%) rename src/{ => wasm}/wasm_stubs.c (95%) diff --git a/build.zig b/build.zig index 6aefb190da..985c47e05a 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 }); } diff --git a/src/nvim/CMakeLists.txt b/src/nvim/CMakeLists.txt index 34d690e764..49ee59fc5f 100644 --- a/src/nvim/CMakeLists.txt +++ b/src/nvim/CMakeLists.txt @@ -443,7 +443,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 47d709dcd4..f471ed3d86 100644 --- a/src/nvim/event/wstream.c +++ b/src/nvim/event/wstream.c @@ -82,11 +82,10 @@ int wstream_write(Stream *stream, WBuffer *buffer) // Synchronous write #ifdef __EMSCRIPTEN__ -// Pass -1 so the write uses the current file position. + // 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); + err = uv_fs_write(stream->uv.idle.loop, &req, stream->fd, &uvbuf, 1, stream->fpos, NULL); #endif uv_fs_req_cleanup(&req); @@ -94,9 +93,7 @@ int wstream_write(Stream *stream, WBuffer *buffer) assert(stream->write_cb == NULL); -#ifndef __EMSCRIPTEN__ stream->fpos += MAX(req.result, 0); -#endif return req.result > 0 ? 0 : err != 0 ? err : UV_UNKNOWN; } diff --git a/src/wasm/app.js b/src/wasm/app.js index 48b4f61e25..57d16286b5 100644 --- a/src/wasm/app.js +++ b/src/wasm/app.js @@ -1,60 +1,393 @@ -const statusEl = document.getElementById('status'); -const logEl = document.getElementById('log'); -const setStatus = s => statusEl.textContent = s; +const DEBUG = false; +const statusEl = document.getElementById("status"); +const setStatus = (s) => (statusEl.textContent = s); const log = (...args) => { - logEl.textContent += args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ') + '\n'; + if (!DEBUG) return; + if (logEl) { + logEl.textContent += + args + .map((a) => (typeof a === "string" ? a : JSON.stringify(a))) + .join(" ") + "\n"; + } console.log(...args); }; -if (!crossOriginIsolated) { - setStatus('NOT cross-origin isolated -- SharedArrayBuffer unavailable. Serve with COOP/COEP headers.'); -} else { - main(); +// 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: 80, rows: 24 }); - transport.onStatus(text => { setStatus(text); log('[status]', text); }); + 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); - setTimeout(async () => { - console.log("Auto Request"); + window.nvim = nvim; // for console debugging - try { - const r = await nvim.request("nvim_get_api_info", []); - console.log(r); - } catch (e) { - console.error(e); + 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; + })(); } -}, 10000); + return apiInfoPromise; + } - nvim.on('redraw', params => log('Notification redraw', params)); - - document.getElementById('apiInfoBtn').addEventListener('click', async () => { - setStatus('sending nvim_get_api_info...'); - try { - const result = await nvim.request('nvim_get_api_info', []); - log('RESPONSE nvim_get_api_info ->', result); - setStatus('got nvim_get_api_info response'); - } catch (e) { - log('ERROR nvim_get_api_info ->', e); - setStatus('nvim_get_api_info failed, see log'); + // 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)); + } + } } }); - document.getElementById('attachBtn').addEventListener('click', async () => { - setStatus('sending nvim_ui_attach...'); - try { - const result = await nvim.request('nvim_ui_attach', [80, 24, { rgb: true, ext_linegrid: true }]); - log('RESPONSE nvim_ui_attach ->', result); - setStatus('UI attached finally. Watching for redraw notifications...'); - } catch (e) { - log('ERROR nvim_ui_attach ->', e); - setStatus('nvim_ui_attach failed, see log'); - } - }); + nvim.on("redraw", (params) => + handleRedrawEvents(uiState, gridEl, modeEl, params), + ); - document.getElementById('persistBtn').addEventListener('click', () => { - transport.persist(); + 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 index 44b38347a4..db24ceb17e 100644 --- a/src/wasm/index.html +++ b/src/wasm/index.html @@ -1,25 +1,53 @@ - + - - -nvim.wasm RPC client - - - -
- - - - booting… -
-
+ + + nvim.wasm RPC client + - - + + + + +
+ +
+ booting… +
+ + + + + + + diff --git a/src/wasm/msgpack.js b/src/wasm/msgpack.js index 52291cb566..a275a1a612 100644 --- a/src/wasm/msgpack.js +++ b/src/wasm/msgpack.js @@ -1,25 +1,62 @@ -// Check that the library is actually loaded. -if (typeof MessagePack === 'undefined') { +if (typeof msgpackr === 'undefined') { throw new Error( - 'MessagePack library not loaded. ' + - 'Check that the CDN script tag for @msgpack/msgpack is present and loads correctly.' + '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 = MessagePack.encode(value); - - // Debug: log the encoded bytes (first few bytes) to verify. + 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; }, - decodeMulti(bytes) { - return [...MessagePack.decodeMulti(bytes)]; // ✅ correct global + 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/protocol.js b/src/wasm/msgpackrpc.js similarity index 66% rename from src/wasm/protocol.js rename to src/wasm/msgpackrpc.js index b8f1fede9f..11d467d9cb 100644 --- a/src/wasm/protocol.js +++ b/src/wasm/msgpackrpc.js @@ -13,22 +13,22 @@ const Protocol = { }, // classifies a decoded msgpack-rpc array into a tagged object. - // throws if `msg` doesn't look like a valid rpc message. + // 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'); + 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 }; + return { kind: "request", msgid, method, params }; } if (type === 1) { const [msgid, error, result] = rest; - return { kind: 'response', msgid, error, result }; + return { kind: "response", msgid, error, result }; } if (type === 2) { const [method, params] = rest; - return { kind: 'notification', method, params }; + return { kind: "notification", method, params }; } - throw new Error('unknown rpc message type: ' + type); + throw new Error("unknown rpc message type: " + type); }, }; diff --git a/src/wasm/nvim-worker.js b/src/wasm/nvim-worker.js index 07b9cbbcf9..f277bf3576 100644 --- a/src/wasm/nvim-worker.js +++ b/src/wasm/nvim-worker.js @@ -1,209 +1,432 @@ -const CAP = 1 << 16; // must match the main thread's ring buffer size -let state, ringData; // state is now Int32Array(3): [head, tail, closed] +const CAP = 1 << 16; + +let state = null; +let ringData = null; + +let moduleRef = null; -let stdinPollCount = 0; let totalBytesRead = 0; const consumedBytes = []; +const STATUS_MSG_CAP = 2000; +let statusMsgCount = 0; +let statusCapNoticeSent = false; -let pendingPollCallbacks = []; -function wakePendingPolls() { - const cbs = pendingPollCallbacks; - pendingPollCallbacks = []; - for (const cb of cbs) cb(65); -} -let lastCheckedTail = -1; - -function checkForNewDataAndWake() { - if (!state) return; // not initialized yet - const head = Atomics.load(state, 0); - const tail = Atomics.load(state, 1); - if (head !== tail && pendingPollCallbacks.length > 0) { - wakePendingPolls(); +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 }); } -setInterval(checkForNewDataAndWake, 20); // check every 20ms - -function popNonBlocking() { - const head = Atomics.load(state, 0); - const tail = Atomics.load(state, 1); - if (head !== tail) { - const b = ringData[tail]; - Atomics.store(state, 1, (tail + 1) % CAP); - totalBytesRead++; - consumedBytes.push(b); - return b; - } - return -1; +const perFdWriteCount = {}; +function bumpFdWriteCount(fd) { + perFdWriteCount[fd] = (perFdWriteCount[fd] || 0) + 1; } -// blocks the Worker thread until a byte is available or shutdown fires. -function popBlocking() { - while (true) { //producer-consumer - const head = Atomics.load(state, 0); - const tail = Atomics.load(state, 1); +/* File descriptor monitored by libuv for stdin. Verify this value + against the startup FD dump, as it may differ across environments. */ - if (head !== tail) { - const b = ringData[tail]; - Atomics.store(state, 1, (tail + 1) % CAP); - totalBytesRead++; - consumedBytes.push(b); - return b; - } +const STDIN_FD = 9; - if (Atomics.load(state, 2) === 1) { - return -1; - } +/* + SharedArrayBuffer layout: + offset 0: + Int32Array: + [0] head + [1] tail + [2] closed - // Blocks here until Atomics.notify(state, 0) fires, or 1s elapses - Atomics.wait(state, 0, head, 1000); - } + offset 12: + Uint8Array data +*/ + +function unreadCount() { + const head = Atomics.load(state, 0); + const tail = Atomics.load(state, 1); + return (head - tail + CAP) % CAP; } -let stdoutBuf = []; -let stderrBuf = []; - -function flushStdout() { - if (stdoutBuf.length) { - postMessage({ type: 'stdout', bytes: stdoutBuf }); - stdoutBuf = []; +// 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); } } -function flushStderr() { - if (stderrBuf.length) { - postMessage({ type: 'stderr', bytes: stderrBuf }); - stderrBuf = []; +// 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() { - flushStdout(); - flushStderr(); + if (stdoutBuffer.length) { + postMessage({ + type: "stdout", + bytes: stdoutBuffer, + }); + stdoutBuffer = []; + } + + if (stderrBuffer.length) { + postMessage({ + type: "stderr", + bytes: stderrBuffer, + }); + stderrBuffer = []; + } } -function writeStdout(c) { - stdoutBuf.push(c); - flushStdout(); -} +let stdoutBuffer = []; +let stderrBuffer = []; function writeStderr(c) { - stderrBuf.push(c); - if (stderrBuf.length > 80) { - flushStderr(); + 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, p = M._malloc(len); + 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 }; + + ptrs.forEach((p, i) => M.setValue(argv + i * 4, p, "*")); + + M.setValue(argv + ptrs.length * 4, 0, "*"); + + return { + argc: ptrs.length, + argv, + }; } -let moduleRef = null; - self.onerror = (e) => { - postMessage({ type: 'status', text: 'WORKER ERROR: ' + e.message }); + safeStatus("WORKER ERROR " + e.message); }; self.onunhandledrejection = (e) => { - postMessage({ type: 'status', text: 'WORKER REJECTION: ' + 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); }; -function makeBridgeReadOps(origOps, label) { - const newOps = Object.assign({}, origOps); - newOps.read = function (stream, buffer, offset, length, position) { - let n = 0; - while (n < length) { - const b = popNonBlocking(); - if (b === -1) break; - buffer[offset + n] = b; - n++; - } - if (n === 0) { - console.log(`[stream_ops:${label}] no data available, returning 0 (temporary — not correct long-term)`); - return 0; // TEMP: fake EOF - } - console.log(`[stream_ops:${label}] read ${n} bytes`); - return n; - }; -newOps.poll = function (stream, timeout, notifyCallback) { - const head = Atomics.load(state, 0); - const tail = Atomics.load(state, 1); +/* 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, + ); - if (head !== tail) { - return 65; + 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 (notifyCallback) { - pendingPollCallbacks.push(notifyCallback); + if (Atomics.load(state, 2) === 1) { + console.log("stdin closed"); + return null; // real EOF } - return 0; -}; - const origGetattr = origOps.getattr; - newOps.getattr = function (stream) { - const attr = origGetattr ? origGetattr.call(this, stream) : { mode: 0 }; - attr.mode = (attr.mode & ~0xF000) | 0x1000; - return attr; + return undefined; // no data yet -> Emscripten turns this into EAGAIN }; - return newOps; } self.onmessage = async (ev) => { const msg = ev.data; - if (msg.type === 'init') { - state = new Int32Array(msg.sab, 0, 3); // [head, tail, closed] + 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 -setInterval(checkForNewDataAndWake, 20); - postMessage({ type: 'status', text: 'loading wasm...' }); - importScripts('../../zig-out/bin/nvim.js'); + 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, + locateFile: (p) => + p.endsWith(".data") + ? "../../zig-out/bin/nvim.data" + : "../../zig-out/bin/" + p, noInitialRun: true, - stdin: () => { - console.log('[stdin callback] fired'); - const b = popNonBlocking(); - return b === -1 ? null : b; - }, - stdout: c => writeStdout(c), - stderr: c => writeStderr(c), - print: t => postMessage({ type: 'status', text: '[print] ' + t }), - printErr: t => postMessage({ type: 'status', text: '[printErr] ' + t }), - preRun: [m => { - m.ENV.TERM = "xterm-256color"; - m.ENV.HOME = "/home/user"; - m.ENV.VIMRUNTIME = "/runtime"; - m.ENV.COLUMNS = String(msg.cols); - m.ENV.LINES = String(msg.rows); - try { m.FS.mkdir('/tmp'); } catch (e) {} - m.FS.mkdir('/home/user'); - m.FS.mkdir('/home/user/.config'); - m.FS.mkdir('/home/user/.local'); - m.FS.mkdir('/home/user/.local/share'); - m.FS.mount(m.IDBFS, {}, '/home/user/.config'); - m.FS.mount(m.IDBFS, {}, '/home/user/.local/share'); - }], - }); + interactive: false, - 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) {} + 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; @@ -212,10 +435,11 @@ setInterval(checkForNewDataAndWake, 20); 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.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()); + 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); @@ -223,18 +447,21 @@ setInterval(checkForNewDataAndWake, 20); } function isStdinLike(path) { - const p = path || ''; - return p.startsWith('pipe[') || p.includes('my_stdin') || p === '/dev/stdin'; + 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; + ["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]; + const path = + name === "fstat" + ? m.FS.streams[args[0]] && m.FS.streams[args[0]].path + : args[0]; if (isStdinLike(path)) { attr.mode = 0o010666; } @@ -242,62 +469,129 @@ setInterval(checkForNewDataAndWake, 20); }; }); - const origCreateStream = m.FS.createStream; - m.FS.createStream = function (stream, fd) { - const s = origCreateStream.call(this, stream, fd); - const p = s.path || ''; - const looksLikeStdin = p.startsWith('pipe[') || p.includes('my_stdin') || p === '/dev/stdin'; - if (looksLikeStdin && !s._patched) { - s.stream_ops = makeBridgeReadOps(s.stream_ops, 'createStream:' + p); - s._patched = true; - } - return s; - }; - - const origFSRead = m.FS.read; - m.FS.read = function (stream, buffer, offset, length, position) { - if (stream.path && stream.path.startsWith('pipe[') && !stream._patched) { - stream.stream_ops = makeBridgeReadOps(stream.stream_ops, 'fallback:' + stream.path); - stream._patched = true; - } - return origFSRead.call(this, stream, buffer, offset, length, position); - }; - const origFSWrite = m.FS.write; m.FS.write = function (stream, buffer, offset, length, position, canOwn) { - return origFSWrite.call(this, 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"]); - let ret; - try { - postMessage({ type: 'status', text: 'Starting Neovim...' }); - ret = await m._nvim_main(argc, argv); - const headAtExit = Atomics.load(state, 0); - const tailAtExit = Atomics.load(state, 1); - const unread = (headAtExit - tailAtExit + CAP) % CAP; - postMessage({ type: 'status', text: `_nvim_main RETURNED ret=${ret}, unread bytes still in buffer=${unread} (head=${headAtExit}, tail=${tailAtExit})` }); + 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(); - postMessage({ type: 'status', text: 'EXCEPTION: ' + e.message }); + 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)` : ''; - postMessage({ type: 'status', text: `EXIT CODE ${ret}. Read ${totalBytesRead} bytes: ${hexStr}${remaining}` }); + 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 === '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 === "stdin") { + pushBytes(msg.bytes); } - if (msg.type === 'shutdown') { + + 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 any pending popBlocking() + 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 index 2aa220f434..761ad5a606 100644 --- a/src/wasm/rpc.js +++ b/src/wasm/rpc.js @@ -1,81 +1,130 @@ -// Transport: owns the worker + SharedArrayBuffer ring buffer +// Transport: owns the worker + 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.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); + 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 }); + 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' }); - } + this.worker.postMessage({ type: "shutdown" }); + } _onWorkerMessage(msg) { - console.log('[main] _onWorkermessage:', msg.type); - if (msg.type === 'stdout') { + 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') { + 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("[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)); + console.log("[main] status:", text); + this._statusHandlers.forEach((h) => h(text)); } send(bytes) { - console.log("[main] SEND: writing", bytes.length, "bytes"); - -// This ensures all data is in the buffer before Nvim wakes up -const hexStr = Array.from(bytes) - .map(b => b.toString(16).padStart(2, '0')) - .join(' '); + 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'); + console.error("[main] Send: ring buffer full"); return false; } - this.ringData[head] = b; Atomics.store(this.state, 0, next); - // wait until all bytes are written + lastHead = next; } - - // notify once after all bytes are in the buffer - Atomics.notify(this.state, 0); - - console.log("[main] SEND DONE, notified worker"); + + 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); } - persist() { this.worker.postMessage({ type: 'persist' }); } + 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 { @@ -86,98 +135,103 @@ class RpcClient { this.notificationHandlers = new Map(); this._buffer = new Uint8Array(0); - this._decodedMessages = []; - - transport.onBytes(bytes => this._handleBytes(bytes)); + + 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 decoder = new MessagePack.Decoder(); - let offset = 0; + const { messages, consumed } = MsgpackCodec.decodeMultiple(this._buffer); - while (offset < this._buffer.length) { - let msg; - try { - // decodes exactly one value starting at `offset` tells us how much it consumed - decoder.setBuffer(this._buffer.subarray(offset)); - msg = decoder.decode(); - } catch (e) { - break; // incomplete trailing message — stop, wait for more bytes - } - offset += decoder.bytesConsumed ?? decoder.pos; // depends on library version + for (const msg of messages) { + console.log("[RpcClient] raw decoded:", JSON.stringify(msg)); this._dispatch(msg); } - // only drop what was actually consumed and keep any incomplete tail - this._buffer = this._buffer.slice(offset); + this._buffer = this._buffer.slice(consumed); } _dispatch(rawMsg) { - console.log('[RpcClient] dispatching:', rawMsg); - + console.log("[RpcClient] dispatching:", rawMsg); + let msg; try { msg = Protocol.parseMessage(rawMsg); } catch (e) { - console.error('[RpcClient] parse failed', rawMsg, e); + console.error("[RpcClient] parse failed", rawMsg, e); return; } - console.log('[RpcClient] parsed:', msg); + console.log("[RpcClient] parsed:", msg); - if (msg.kind === 'response') { + if (msg.kind === "response") { const p = this.pending.get(msg.msgid); if (!p) { - console.warn('[RpcClient] unknown msgid', msg.msgid); + 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); + 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); + } 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); + 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`); - + 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() }); - + 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 batches all bytes before notifying + this.transport.send(bytes); }); } notify(method, params = []) { const bytes = Protocol.encodeNotification(method, params); - console.log(`[RpcClient] sending notification ${method}, ${bytes.length} bytes`); + console.log( + `[RpcClient] sending notification ${method}, ${bytes.length} bytes`, + ); this.transport.send(bytes); } diff --git a/src/wasm/serve.py b/src/wasm/serve.py index 753935f9e7..bff568c96b 100644 --- a/src/wasm/serve.py +++ b/src/wasm/serve.py @@ -1,8 +1,18 @@ #!/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 = 8001 +PORT = 8002 + +ROOT = Path(__file__).resolve().parents[2] class Handler(http.server.SimpleHTTPRequestHandler): def end_headers(self): @@ -11,6 +21,8 @@ class Handler(http.server.SimpleHTTPRequestHandler): super().end_headers() if __name__ == "__main__": - with socketserver.TCPServer(("", PORT), Handler) as httpd: - print(f"Serving with COOP/COEP on http://localhost:{PORT}") + 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 index d27d30a75a..640b80682a 100644 --- a/src/wasm/style.css +++ b/src/wasm/style.css @@ -1,5 +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; } -#log { white-space: pre-wrap; background: #111; padding: 10px; border-radius: 4px; } +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_stubs.c b/src/wasm/wasm_stubs.c similarity index 95% rename from src/wasm_stubs.c rename to src/wasm/wasm_stubs.c index 830121d23e..a83bf6d66a 100644 --- a/src/wasm_stubs.c +++ b/src/wasm/wasm_stubs.c @@ -1,6 +1,7 @@ #include #include #include +#include #include #include #include @@ -12,19 +13,6 @@ #include #include -#ifndef POLLIN -# define POLLIN 0x001 -#endif -#ifndef POLLOUT -# define POLLOUT 0x004 -#endif -#ifndef POLLERR -# define POLLERR 0x008 -#endif -#ifndef POLLHUP -# define POLLHUP 0x010 -#endif - void uv__io_cb(uv_loop_t *loop, uv__io_t *w, unsigned events); // System info stubs @@ -87,7 +75,7 @@ int uv_exepath(char *buffer, size_t *size) } // Browsers have no native CPU information. So return a single virtual CPU -int uv_cpu_info(uv_cpu_info_t **cpu_infos, int *count) +int uv_cpu_info(uv_cpu_info_t * *cpu_infos, int *count) { if (!cpu_infos || !count) { return UV_EINVAL; @@ -107,7 +95,7 @@ int uv_cpu_info(uv_cpu_info_t **cpu_infos, int *count) } // Browsers do not expose host network interfaces. Report only loopback. -int uv_interface_addresses(uv_interface_address_t **addresses, int *count) +int uv_interface_addresses(uv_interface_address_t * *addresses, int *count) { if (!addresses || !count) { return UV_EINVAL; From 507978799367a621631e3be63a05d54ce682d5bd Mon Sep 17 00:00:00 2001 From: rawan10101 Date: Mon, 3 Aug 2026 14:45:49 +0300 Subject: [PATCH 6/6] docs: mention wasm32-emscripten build target in news.txt --- runtime/doc/news.txt | 4 ++++ src/wasm/rpc.js | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 360a3114a3..b19dbf933d 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -191,6 +191,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/wasm/rpc.js b/src/wasm/rpc.js index 761ad5a606..ecaf47942c 100644 --- a/src/wasm/rpc.js +++ b/src/wasm/rpc.js @@ -1,4 +1,5 @@ -// Transport: owns the worker + SharedArrayBuffer +// 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;