EnglishРусский Map

Добавление 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
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, потому что у этих двух таргетов разные контракты с хостом, и выбор не того таргета приводит ровно к описанной здесь проблеме.