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
This commit is contained in:
rawan10101
2026-07-28 15:32:17 +03:00
parent e0dd164fb4
commit 17d1ed53ff
12 changed files with 1130 additions and 376 deletions

View File

@@ -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 });
}

View File

@@ -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
)

View File

@@ -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;
}

View File

@@ -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 += `<span class="cursor" style="${style}">${ch}</span>`;
} else if (style) {
html += `<span style="${style}">${ch}</span>`;
} 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 "&amp;";
if (ch === "<") return "&lt;";
if (ch === ">") return "&gt;";
return ch;
}
function translateKey(ev) {
const isCtrl = ev.ctrlKey || ev.metaKey;
const isAlt = ev.altKey;
const named = {
Backspace: "<BS>",
Enter: "<CR>",
Escape: "<Esc>",
Tab: "<Tab>",
ArrowUp: "<Up>",
ArrowDown: "<Down>",
ArrowLeft: "<Left>",
ArrowRight: "<Right>",
Delete: "<Del>",
Home: "<Home>",
End: "<End>",
PageUp: "<PageUp>",
PageDown: "<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 = "<lt>";
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();
}

View File

@@ -1,25 +1,53 @@
<!DOCTYPE html>
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>nvim.wasm RPC client</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="bar">
<button id="apiInfoBtn">1. Send nvim_get_api_info</button>
<button id="attachBtn">2. Send nvim_ui_attach</button>
<button id="persistBtn">Persist now</button>
<span id="status">booting…</span>
</div>
<div id="log"></div>
<head>
<meta charset="utf-8" />
<title>nvim.wasm RPC client</title>
<link rel="stylesheet" href="style.css" />
<!-- msgpack library -->
<script crossorigin src="https://unpkg.com/@msgpack/msgpack@3.1.2/dist.umd/msgpack.min.js"></script>
<style>
#grid {
font-family: "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 14px;
line-height: 1.35;
white-space: pre;
background: #050912;
color: #dfe5f1;
border: 1px solid #2a3346;
border-radius: 8px;
padding: 8px;
outline: none;
overflow: auto;
}
<script src="msgpack.js"></script>
<script src="protocol.js"></script>
<script src="rpc.js"></script>
<script src="app.js"></script>
</body>
#grid:focus {
border-color: #5ad1ff;
}
.cursor {
outline: 1px solid currentColor;
}
#bar {
display: none;
}
</style>
</head>
<body>
<div id="grid" tabindex="0" aria-label="Neovim grid"></div>
<div id="bar">
<span id="status">booting…</span>
</div>
<script
crossorigin="anonymous"
src="https://cdn.jsdelivr.net/npm/msgpackr/dist/index.js"
></script>
<script src="msgpack.js"></script>
<script src="msgpackrpc.js"></script>
<script src="rpc.js"></script>
<script src="app.js"></script>
</body>
</html>

View File

@@ -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 || '');
}

View File

@@ -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);
},
};

View File

@@ -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
}
}
};

View File

@@ -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);
}

View File

@@ -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()

View File

@@ -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;
}

View File

@@ -1,6 +1,7 @@
#include <emscripten.h>
#include <emscripten/threading.h>
#include <math.h>
#include <poll.h>
#include <pthread.h>
#include <stdatomic.h>
#include <stddef.h>
@@ -12,19 +13,6 @@
#include <time.h>
#include <uv.h>
#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;