Добавление Golang в браузерный раннер кода
- title
- Добавление Golang в браузерный раннер кода
- type
- summary
- summary
- GOOS=js зависает в изоляте V8; GOOS=wasip1 и WASI-шим на 50 строк запускают Yaegi
- tags
- go, webassembly, sandboxing, javascript
- created
- 2026-07-23
- updated
- 2026-09-01
- lang
- ru
- translation_of
- adding-go-to-a-browser-code-runner
- source_updated
- 2026-09-01
- translated
- 2026-09-01
- translator
- lllm/antigravity/gemini-3.7-flash-medium
Ата Куюмджу (Ata Kuyumcu) добавляет Golang четвёртым языком на dailyprog - сайт с ежедневными задачками по программированию, исполняющий код пользователей. JavaScript там был нативным, Python работал через Pyodide, а C - через WASM-бинарник PicoC с кастомной обвязкой для printf. Все туториалы по компиляции Go в WASM указывают на GOOS=js GOARCH=wasm, но для этого конкретного хоста цель оказалась неверной.
Почему GOOS=js не работает вне браузера
dailyprog запускает пользовательский код внутри isolated-vm - голого изолята V8 без DOM, без event loop и с урезанным API. Стандартный рецепт запуска из wasm_exec.js вызывает WebAssembly.instantiate(), который возвращает Promise. Внутри изолята очередь микротасок никто не крутит, поэтому Promise никогда не резолвится, а программа висит до таймаута. Видимым симптомом была ошибка Maximum call stack size exceeded, из-за которой автор два дня ковырял размеры стека V8; флаг --stack-size до изолятов всё равно не доходит, а переполнение стека оказалось лишь следствием зависшей инициализации.
Вторая проблема всё равно поставила бы крест на решении, даже если бы удалось обойти первую. Мост syscall/js работает через установку свойств в globalThis. В браузере это window. В изоляте же globalThis, захваченный мостом при старте, не совпадает с тем, который видит гостевой код, поэтому колбэки улетают в пустоту. Мост вкомпилирован прямо в WASM, так что со стороны хоста пропатчить ничего нельзя.
GOOS=js рассчитывает на полноценный браузер и ломается так, как нигде не описано, потому что Golang WASM внутри изолята V8 почти никто не запускает.
WASI - подходящий размер
Таргет GOOS=wasip1 GOARCH=wasm появился в Golang в версии 1.21. Контракт у WASI гораздо компактнее, чем у JS-моста: буфер линейной памяти и набор импортируемых функций для файлового ввода-вывода, часов, переменных окружения и генератора случайных чисел. Никаких Promise, никакого event loop, никакого globalThis. Вызов fmt.Println превращается в вызов fd_write, и реализация fd_write - это фактически вся работа хоста:
function fd_write(fd, iovs, iovs_len, nwritten) {
let written = 0;
for (let i = 0; i < iovs_len; i++) {
const ptr = mem.getUint32(iovs + i * 8, true);
const len = mem.getUint32(iovs + i * 8 + 4, true);
const text = readStr(ptr, len);
stdout.push(text.endsWith("\n") ? text.slice(0, -1) : text);
written += len;
}
mem.setUint32(nwritten, written, true);
return 0;
}
Каждый элемент iovs - это пара {смещение, длина} в линейной памяти; декодируем UTF-8, отрезаем перенос строки, добавленный Println, и записываем число байт в nwritten, поскольку Golang его проверяет. Остальное тривиально: args_get передаёт исходник пользователя в argv[1], clock_time_get возвращает performance.now() в наносекундах, random_get заполняет буфер через Math.random(), proc_exit бросает исключение со специальным префиксом-маркером (чтобы отличить штатный выход от падения), вызовы для файловых дескрипторов возвращают EBADF, а всё остальное - ENOSYS (52). Суммарно около пятидесяти строк кода на стороне хоста.
В качестве гостя взят Yaegi - написанный на Golang интерпретатор Golang от Traefik, скомпилированный под wasip1. Обвязка занимает примерно 30 строк: interp.New, загрузка stdlib.Symbols, чтение os.Args[1] и Eval. Итоговый бинарник весит 38 MB.
Ловушка однократного запуска
Функция _start в WASI вызывается один раз. Если вызвать её повторно на том же WebAssembly.Instance, рантайм Golang падает с fatal error: randinit twice, либо fatal error: self deadlock, либо - вариант, на который уйдёт полдня, - вообще без ошибок: Yaegi просто завершается до первого вызова fmt.Println, а раннер выдаёт "No output for this case."
Решение разделяет запуск WASM на дорогую и дешёвую части. Компиляция модуля на 38 MB - это дорого, поэтому кэшируем WebAssembly.Module; инстанцирование дёшево, поэтому на каждый запуск создаём свежий Instance:
let mod = null;
export async function runGo(source, callback) {
if (!mod) mod = await WebAssembly.compile(bytes);
const instance = new WebAssembly.Instance(mod, imports);
memory = instance.exports.memory; // the shims must follow the new memory
instance.exports._start();
}
Строчка, которую легко упустить, - перепривязка памяти: WASI-шимы замыкают DataView линейной памяти, и если оставить указатель на память предыдущего инстанса, вместо ошибки полетит мусор. На стороне сервера аналог этого - создание свежего изолята isolated-vm на каждый запуск, с холодным стартом около 4 секунд.
Затраты и общий шаблон
Поскольку dailyprog уже унифицировал все четыре языка в единый пайплайн генерации кода, запуска, разбивки вывода и проверки результатов, специфичная для Golang часть заняла всего семь файлов (три теста, включая бинарник на 38 MB) плюс однострочные правки в общих модулях. Серверный GoSandbox - это копия CSandbox примерно на 20 строк. Браузерный раннер - модуль на 130 строк, который Web Worker импортирует лениво. Кодогенерация обвязки выдаёт функцию main() с захардкоженными литералами Golang и вызовами fmt.Println(prefix, call) на каждый тест-кейс; при этом вывод типов маппит значения из JS в int, float64, string, bool и срезы, а parseGoLine конвертирует результаты обратно для проверки через deepEqual.
Вывод, который делает автор: в эту же схему укладывается любой язык, чей селф-хостед интерпретатор компилируется в wasm32-wasip1. Интерпретатор Lua на C через clang, интерпретатор Ruby на Rust, существующие сторонние WASI-сборки PHP. WASI не назовёшь элегантным - это POSIX-подобный интерфейс системных вызовов, - но его контракт достаточно компактен, чтобы корректно реализовать его за полдня, и по ту сторону моста нет второго рантайма, требующего собственного event loop.
Связанные страницы
webassembly - базовый таргет. wazero представляет собой зеркальное отражение этой схемы: WASI-совместимый хост на Golang вместо гостя на Golang под управлением JS-хоста; к обоим применим один и тот же принцип: "граница песочницы определяется экспортируемыми импортами".
antonz-org - блог создателя Codapi, платформы интерактивных примеров кода с теми же задачами, что и у раннера dailyprog; именно для руководств в духе "Попробуйте X за Y минут" такие раннеры и создаются.
wanix приходит к тому же выводу конструктивно: там gojs и wasi разнесены по отдельным драйверам задач, а не объединены в один драйвер Wasm, потому что у этих двух таргетов разные контракты с хостом, и выбор не того таргета приводит ровно к описанной здесь проблеме.