A pure-Go, CGO_ENABLED=0 headless web engine: it fetches a URL, parses the
HTML into a DOM, applies a real CSS subset (cascade + inheritance + var() +
modern colour + dark-mode + gradients), runs the page's JavaScript against a
real DOM binding, lays the content out with a full box model (block, inline,
float, flexbox, CSS grid, tables and position), and paints anti-aliased text,
backgrounds, gradients, box-shadows, images and SVG to an image.RGBA — no
Chromium, no cgo, no host web view. Give it a URL, get back an image of the
page.
It is the rendering core of the
browserproxy remote-browser
service and the wasmdesk in-desktop browser: a
server renders pages and streams frames (plus a click hit-map) to a thin client.
import (
"context"
"image"
"os"
"github.com/go-webengine/engine"
)
func main() {
ctx := context.Background()
// Render a live page to PNG bytes.
png, err := engine.Screenshot(ctx, "https://example.com/", image.Rect(0, 0, 1024, 768))
if err != nil {
panic(err)
}
_ = os.WriteFile("out.png", png, 0o644)
// Or get the raw image plus metadata (title, final URL, full content height).
img, info, err := engine.Render(ctx, "https://example.com/", image.Rect(0, 0, 1024, 768))
_ = img
_ = info // info.Title, info.URL, info.ContentHeight
_ = err
}For more control use an *engine.Engine (from engine.New()): it adds
RenderHTML (render a local HTML string offline), RenderWithLinks /
RenderDocumentWithLinks (image plus a []Link hit-map for click-to-navigate),
and a DisableJS field to render the static, no-JavaScript document.
The viewport width is fixed; the height grows to fit the whole page (at least the viewport height).
LayoutHTML / LayoutDocument run the same pipeline without painting and
return a *PageLayout. Paint() gives the image RenderHTML would;
PaintVector(c) draws the same boxes through a paint.Canvas instead — paths,
shadings, images placed in their boxes and positioned glyphs of the very faces
the layout measured with. The pdfcanvas package is that canvas for a
go-pdfkit page: text stays text
(subset-embedded Type0 fonts with a /ToUnicode map — selectable,
searchable), edges stay sharp at any zoom, and an image used on many pages is
embedded once.
l, _ := eng.LayoutHTML(ctx, html, baseURL, image.Rect(0, 0, 1280, 720))
doc := pdfkit.New(pdfkit.Options{Compress: true})
page := doc.AddPage(pdfkit.NewPageSize(960, 540)) // a CSS px is 3/4 pt
stats := l.PaintVector(pdfcanvas.New(page, 0.75, pdfcanvas.NewFonts()))Tagged PDF (after v0.13.2): c.Tag(doc.Structure()) before
PaintVector, then c.Finish(), writes a tagged PDF that a screen reader can
read. PaintVector tells a paint.StructuredCanvas which DOM node each
drawing belongs to (paint.Mark). pdfcanvas builds the structure from the
DOM as a browser's accessibility tree does:
- headings, paragraphs, lists (
L/LI/Lbl/LBody), tables (with eachTH'sScope), links, images and formulas, with theiralt,aria-labelandlang, in reading order; - backgrounds, borders, generated text (
::before/::after),user-select: noneandaria-hiddencontent as artifacts.
c.Element(link.Node) ties a link annotation to its Link element. Pages
built this way pass veraPDF's PDF/UA-1 profile.
What has no vector form here — a subtree under opacity < 1, a filter,
mask-image or transform: rotate(), a blurred box-shadow, a gradient
whose opacity fades between two stops, a form control — is painted by the
raster painter as a bitmap of that element only, placed where it was, with
its text laid over it invisibly (text rendering mode 3) so that it can still
be selected; stats.Rasterized counts them. Laying out and painting are split
so that pages can be laid out concurrently and painted, in order, into one
document.
SVG stays vector (v0.22.0). An inline <svg>, an <img src="x.svg"> or a
data:image/svg+xml, and an SVG background are drawn by PaintVector as the
shapes they paint, not as their bitmap. This covers a go-tex formula, an emoji
and a logo. The shapes come from go-gfx/oksvg's Shapes, which follows what its
rasteriser draws:
- fills keep their curves and fill rule;
- strokes become their outlines, with width, caps, joins and dashes;
- linear and radial gradients become shadings;
- clip paths become clips;
- opacities go into the alpha.
stats.Vectors counts them. The outputs differ:
- pdfcanvas draws each distinct drawing once per document as a form XObject
and paints it with one
Doat each use, inside its Figure's or Formula's marked content. A formula's glyphs are outlines (go-tex/math draws them as paths), so its drawing also carries its TeX as/ActualText, which is what a reader copies and a search finds. - officecanvas records the shapes as one
KindGroup, which go-slides writes as ap:grpSpordraw:gof custom shapes. A slide's shape cannot be clipped, and an office radial gradient does not end at a radius. So an SVG that needs a clip, a box that cuts it (a background tile), or a radial gradient is placed as its picture there. - The bitmap render does not change, and never computes the vector form: it is made the first time a vector canvas asks for it.
An SVG is still placed as its bitmap in these cases:
Shapeshas no exact form for it: a gradient whose stops change opacity, a reflected or repeated one, a focal radial one, or a clipPath of clipped pieces;- a
filterapplies to the image; - it is past
paint.MaxVectorOps(200 000 path operations); - it has a stroke of more than 65 536 dashes.
The library side of this is paint.PaintVectorWith with paint.VectorImages.
A canvas opts in to paint.VectorCanvas (it places the image itself),
paint.GroupCanvas (it keeps the shapes together) and paint.VectorSupporter
(it says what it can draw).
officecanvas is the other paint.Canvas consumer: it records a page as
the objects an editable office slide is made of — filled shapes (rectangles,
rounded rectangles, rings as outlines with holes), gradient shapes (a CSS
gradient's constant-opacity pieces merged back into one gradient, transparent
across the gaps), one text box per line of words (runs split by face, size,
colour, link and letter-spacing, the gap between words a space), and pictures
cropped to their clip — plus the page background and the faces used (family
and style from the font's name table, embeddability from its OS/2 fsType;
SFNT reassembles the very font file the page was laid out with). A writer
maps the Page to DrawingML or ODF; go-slides does both. A slide cannot clip,
so a picture is cropped to its clip and a gradient takes its clip's outline;
Shape.Render draws a gradient as a picture for a format whose gradients are
poorer.
Engine.Adjust runs Go where a page's scripts run. It reads the layout through a
*Geometry, which answers what a script reads through CSSOM View:
OffsetWidth, ClientWidth, Rect, and getComputedStyle's Computed, laid out
again after each DOM change. It may change the DOM, for example with
dom.Node.SetStyleProperty as el.style.prop = … does. The render then settles as
it does after scripts. JSParseFloat and JSNumberString are JavaScript's
parseFloat and number-to-string, so a native port of a script computes the same
lengths. go-slides fits Marp's headings this way: a test checks that the native
fit and the script render the same pixels.
Engine.Fetcher replaces the HTTP client for every http(s) URL a render
needs: the page, its style sheets, images and fonts. It suits a host whose
platform fetches for it, such as a browser's fetch in WebAssembly.
Fonts are loaded on demand. Each bundled face (Inter, Lora, Go Mono, all in regular, bold,
italic and bold italic, and DejaVu Sans for the characters they lack) is parsed the first time a render draws it,
not all at once. A host's installed face can be lazy too:
LazyFontFace(family, weight, italic, load) fetches its file when its family is
first drawn, and passes it over if the fetch fails.
Three build tags leave out what a host does not need:
| tag | leaves out | what changes |
|---|---|---|
webengine_nojs |
goja, esbuild, Engine.Open / LiveDocument / form submission |
no page script runs; Adjust still does |
webengine_nonet |
browserhttp (Chrome TLS fingerprint), the HTTP client; with webengine_nojs too, all of net/http |
an http(s) URL goes to the host's Engine.Fetcher, or is refused with ErrNoNetwork; data: and file: (under FileRoot) load as usual |
webengine_fontsext |
the 16 bundled font files | the host serves paint.BundledFontNames() (the bytes are paint/bundledfonts) and calls paint.SetFontSource before the first render |
webengine_nojs takes go-slides' WebAssembly export worker from 20.9 to 16.5
MiB gzipped (measured 2026-10-25). With webengine_fontsext, a deck that draws
only Inter Regular and Go Mono Regular downloads those two files, not the other 14.
There is also a CLI:
go run ./cmd/render -url https://example.com/ -out out.png -w 1024 -h 768
# or render a local file offline; its own directory is the base and the file
# root, so a relative @font-face font or image beside it loads:
go run ./cmd/render -file page.html -out out.png
# (an explicit -base https://example.com/ resolves against that site instead,
# and keeps local files out of reach)
Fetch (go-browserhttp) → Parse (x/net/html → dom) → Cascade (css: +var()/@media/dark-mode)
→ JavaScript (js: goja + real DOM + fetch/XHR)
→ Layout: block · inline · float · flex · grid · table · position (layout)
⟲ settle loop: JS reads laid-out metrics → re-cascade + re-layout to a bounded fixpoint
→ Paint: AA text/SVG/gradients/shadows (paint) + backgrounds (go-widgets/painter) + images (go-images)
→ image.RGBA → PNG (+ optional link hit-map)
| Package | Role |
|---|---|
dom |
Owned DOM node tree built from golang.org/x/net/html. |
css |
Real CSS subset: value model, stylesheet/declaration parser, tag/class/id + descendant/child/sibling combinators + :checked/:not() selectors with specificity, var() custom properties, @media width queries, modern colour (rgb()/hsl()), dark-mode, UA stylesheet, cascade + inheritance. |
layout |
Full box model — block-and-inline flow, floats + clear, flexbox, CSS grid, tables, position (relative/absolute/fixed/sticky), margin collapsing, greedy word-wrap — driven by a Measurer interface (font-free, exactly testable). |
js |
JavaScript execution via goja bound to a minimal real DOM, with fetch()/XHR and laid-out-geometry read-back (getBoundingClientRect, offset*, getComputedStyle), plus WebAssembly via wazero. |
paint |
Rasterises the box tree to *image.RGBA — AA text (real bold + italic), gradients, border-radius, box-shadow, opacity, images and SVG; also the real Measurer (go-opentype faces). PaintVector draws the same tree through a vector Canvas. |
pdfcanvas |
A paint.Canvas drawing into a go-pdfkit page: vector PDF with real text. |
officecanvas |
A paint.Canvas recording a page as editable office objects (shapes, gradients, text lines, pictures, fonts) for PPTX/ODP writers. |
engine (root) |
Fetch, Render, Screenshot, RenderHTML, RenderWithLinks, LayoutHTML → PageLayout (paint as image or vectors), the settle-then-render loop, image + SVG loading, and the anchor hit-map. |
cmd/render |
CLI: render -url URL -out shot.png -w 1024 -h 768 (or -file page.html). |
Everything reused is pure-Go and BSD/MIT — see SURVEY.md for the
prior-art verdict (opossum/mycel studied, not built on) and the full
reuse-vs-build decision.
The full per-feature and per-page assessment (ten live pages, committed golden
PNGs, measured vs headless Chrome) is in FIDELITY.md and
bench/REPORT.md. Short version:
Works today
- HTML → DOM → full box-model layout at a real viewport width: block/inline
flow, floats + clear, flexbox, CSS grid, tables,
position(relative/absolute/fixed/sticky), margin collapsing, greedy word-wrap. - CSS cascade with specificity (inline > id > class > tag) and inheritance;
var()custom properties;calc();@mediawidth queries; dark-mode (prefers-color-scheme); external<link>stylesheet fetch; UA defaults.font-size: clamp()(round 190) for fluid typography —rem/vw/px/em/%terms, resolved eagerly at cascade time (min()/max(), andclamp()on any other property, remain unimplemented — see below). The small/large/dynamic viewport-percentage unitssvh/lvh/dvhandsvw/lvw/dvw(round 193) are accepted everywhere a plainvh/vwis, collapsing to the same value — this engine has no scrolling/mobile-chrome concept for the three variants to diverge over. - Selectors: tag/class/id/compound, descendant + child + sibling (
~/+) combinators,:checkedand:not()(the checkbox-hack that collapses MediaWiki dropdowns), attribute selectors handled by a "reduce, don't drop" rule.::before/::aftergenerate a real box for acontentmade of strings,counter()andcounters()(the breadcrumb separator, the numbered heading), including their ownbackground-image/mask-image(the icon-only separator idiom, e.g. MDN's breadcrumb chevron) — not yet on a form control, which is still laid out as an atomic box. CSS escapes ("\2022\00a0",.\31 0) are decoded per CSS Syntax 3 in every string and identifier, selectors included. - Counters:
counter-reset(withreversed()),counter-increment,counter-set, their scoping and inheritance (CSS Lists 3 §4), and the implicitlist-itemcounter shared with list markers. - Colour & decoration: named/
#rgb/#rrggbb, modernrgb()/hsl(),oklab()/oklch(),color-mix()(srgb/srgb-linear/oklab/oklch/hsl),background-color, linear & radial gradients (sized, positioned and tiled bybackground-size/-position/-repeat, v0.45.10),background-image: url(), border + border-radius, box-shadow, group opacity. - Transforms and effects:
translate,rotateandscale(each the function and the standalone property, one at a time — no mixing), CSSfilter(blur and colour-matrix functions),backdrop-filter,mask-image(a singleurl()mask),clip-path: polygon()(round 194) — including acalc()term mixing a percentage with an absolute length, resolved at paint time against the element's own border-box size — groupopacity. - Lists and columns:
ul/olmarkers generated in layout as a::markerbox, so every backend gets them as text (the raster canvas, the vector PDF, and a caller walking the layout tree such as go-pdfkit/html2pdf):list-style-typedisc,circle,square,decimal,decimal-leading-zero,lower-/upper-alpha(-latin),lower-/upper-roman(decimal outside 1–3999), a<string>andnone;list-style-positionoutsideandinside;::markercolour, font andcontent; HTMLstart,reversed,valueandtype. Multi-column layout forcolumns,column-countandcolumn-width. - Text: anti-aliased proportional text (go-opentype) with real bold and
italic faces (no faux-bold), serif / sans / mono, complex scripts (Cyrillic,
Vietnamese, …);
white-space: preandnowrap;text-transform(uppercase/lowercase/capitalize, applied before measurement);letter-spacing,word-break/overflow-wrap,text-overflow: ellipsis,-webkit-line-clamp;@font-face— the document's own typefaces are fetched and registered before anything is measured, WOFF2 included, so a page is set in the face it asked for and not in a substitute at other metrics. A host can also install typefaces of its own withEngine.FontFaces(built withParseFontFace), as a browser's system fonts; parsed fonts are shared across renders, so a page set in the same faces as the last one does not parse them again. The raster painter draws text at the exact size with a fractional pen and quarter-pixel glyph origins, so word positions and widths agree with Chrome to within half a pixel (v0.45.12). A variable font (anfvarwghtaxis) is drawn at eachfont-weightthe page asks for, in raster and PDF alike, and so are the bundled Inter and Lora regulars (v0.45.10); thefontshorthand expands to its longhands (v0.45.11). - Tables: automatic table layout (CSS 2.1 §17.5.2.2) — every column keeps
at least its longest unbreakable word, percentage and fixed cell widths are
honoured, surplus goes to the auto columns;
colspan,border-spacing,vertical-alignon cells (with the UAmiddledefault and thevalignattribute), and a row's own CSSheightas its minimum. Adisplay:blocktable keeps its grid: rows under a non-table box are wrapped in an anonymous table (CSS 2.1 §17.2.1, v0.45.10), andwidth: min-content | max-content | fit-contentsizes it. - Form controls: inputs and buttons with UA chrome, and checkboxes/radios
honouring
appearance: none(custom toggle switches). - Containers and shadow DOM:
@containersize queries, declarative shadow DOM (<template shadowrootmode>) with<slot>distribution, and (round 183) imperativeattachShadow()/customElements.define()— upgrading an already-parsed element whose constructor attaches a shadow tree, the custom-element polyfill idiom confirmed live on developer.apple.com. - Images:
<img>over http(s) +data:(PNG/JPEG) and SVG (go-gfx/oksvg, a fork of srwiley/oksvg withclipPath, over rasterx) via<img *.svg>,data:image/svg+xmland inline<svg>.<picture>/<source>/srcset(round 195): a<source>whosemediamatches the current viewport wins over the<img>'s own fallbacksrc— only the first candidate of a matching<source>'ssrcsetis taken (notype-attribute filtering, no width/density descriptor selection). - JavaScript: page scripts run via goja
against a real DOM, with
fetch()/XHR and read-back of real laid-out geometry (getBoundingClientRect,offsetWidth/Height,getComputedStyle). A settle-then-render loop re-cascades and re-lays-out after scripts mutate the DOM (incl. dynamically injected<script>/<style>/<link>), to a bounded fixpoint — somw.loader-style runtime chrome is reflected in the output. The same JS-settled DOM drives the click hit-map.form.requestSubmit()(round 192) fires a real, cancelablesubmitevent so a page's ownonsubmit-drivenfetch()submission runs.document.location(round 199) is the SAME objectwindow.locationreturns, not a separate one — reading or navigating through either accessor stays consistent.document.referrer(round 200) is always""— honest, since this engine's single-URL fetch has no referring-page history to report.window.postMessage()(round 201) is a no-op — this engine's single-document model has no second window/frame to deliver to (a realMessagePort.postMessage, viaMessageChannel, is unaffected and delivers for real). - WebAssembly (round 189): a page's own
WebAssembly.compile/instantiate(and, since 2026-11-03,compileStreaming/instantiateStreaming—instantiateStreaming(fetch(url)), the overwhelmingly common real shape) runs real.wasmmodules via wazero (pure Go, no CGO — its Interpreter engine covers every architecture this project cross-builds for; its Compiler engine speeds up amd64/arm64 where available). A module's own exported functions and linear memory (instance.exports.memory.buffer, a real zero-copyArrayBuffer) are modelled, with function imports wired to real JS callbacks. Bounded like every other resource this engine budgets: a configurableEngine.MaxWasmModuleBytes/MaxWasmMemoryBytesquota (zero keeps the 32 MiB/16 MiB defaults; raise either toward the wasm32 4 GiB ceiling for genuinely larger real modules), and the same wall-clock deadline JS execution is already held to. - Automation: wait for a page to post "done" (2026-11-03,
Engine.EnableDoneSignal): off by default (no behaviour change, not even an extra global). On, it installs__webengineDone(value), which a page's own script calls once its async work is actually finished; the settle loop (RenderDocument/RenderHTML) then returns as soon as that fires, instead of running out the full pass/JSTimeoutbudget — the "post done, meaning async completion with a timeout" shape an automation caller needs, without any WebDriver/CDP protocol. - Dedicated Workers (2026-11-04,
new Worker(scriptURL), engine#342): a real second goja runtime on its own goroutine — goja supports oneRuntimeper goroutine but never passing object values between them, sopostMessagecrosses the boundary through a bounded, VM-agnostic structured-clone subset (booleans/numbers/strings/ plain Array/plain Object; a function, Symbol, Date/RegExp/Map/Set, or ArrayBuffer/TypedArray throws, matching the real algorithm's ownDataCloneErrorrather than silently losing the value —SharedArrayBuffertransfer is a later milestone). The round trip is deliberately synchronous/blocking rather than truly async (this engine has no background event loop to deliver an async reply into), bounded by the render's own wall-clock budget either way;w.terminate()/self.close()/ the session itself closing all stop the worker's goroutine. Engine.EnableWorkerStreaming(2026-11-04): a Worker's own unsolicited, streamed messages (a container posting stdout for its whole lifetime, not replying to a specificpostMessagecall) need the settle loop to keep running past its own DOM-change fixpoint — off by default (zero behaviour change, not even the extra real-time polling cost). On, a live loop drains timers and Worker inboxes on a real ~15ms wall-clock cadence until the page signals__webengineDone(pair this withEnableDoneSignaland a large enoughJSTimeout) orJSTimeoutelapses either way — never open-ended. Confirmed needed directly by go-wasmcontainers after M2 shipped: their containers stream stdout/ stderr/exit-status/GPU-frame messages continuously, which M2's own synchronous-replypostMessageround trip alone cannot surface.WorkerGlobalScope.importScripts()(2026-11-05, engine#342 follow-up): every url is resolved/validated first (a bad one throwsSyntaxErrorbefore any fetch, per spec), then fetched and run IN TURN sharing the worker's own global scope like multiple top-level<script>tags. A fetch failure or an uncaught exception in an imported script propagates to the calling script (spec's own "rethrow errors") — the one case where this engine's usual "a script error never propagates" rule doesn't apply. Fixed alongside it: a worker's own relative-URL base was wrongly the creating document'sPageURL, not the worker's own script URL — found writing this feature's own test (a worker not co-located with the main page, exactlyimportScripts("wasm_exec.js")'s real shape), not assumed.- A Worker's own global scope now has the full general-purpose (non-DOM)
surface the main document has (2026-11-07, engine#342 follow-up):
crypto/WebCryptoAPI,TextEncoder/TextDecoder,fetch/XMLHttpRequest/Headers/Request/MessageChannel,URL,URLSearchParams,Intl, andperformance.now(). Found by auditing every remaining call in the main document's owninstall()aftercrypto.getRandomValuesturned out to be missing from a Worker for the SAME reasonDOMExceptionwas (engine#342's own first follow-up): each rode along with a DOM-specific install path a Worker correctly never runs, despite having no DOM dependency of its own — confirmed by reading each one's own body, not assumed, before being added here.fetch()inside a Worker also benefits from the relative-URL base fix above: a worker's ownfetch('./sibling.json')resolves against the WORKER's own script location, not the creating document's. - Hex literals survive their own trailing hex digits;
forceFallback- Adapterresolves null (two fixes, 2026-11-08, round 31, go-wasmcontainers report): reported from their real CI lane (GitHub's macOS runner) after v0.45.8.litMSLstripped a numeric literal's own type suffix withTrimRight(text, "iuhfIUHF")— a whole cutset, repeatedly removed, not one character; a hex literal's digits run through a-f/A-F, overlapping that cutset, so0xFFFFFFFFulost its realusuffix correctly then kept eating every trailingFhex digit too, down to bare0x, whichstrconvrefused outright. Fixed with astripOneSuffixthat removes exactly one trailing occurrence of the ONE suffix letter each numeric kind could ever have — an integer branch never stripsf/F, since a hex integer literal has no float suffix at all. Separately,requestAdapter({forceFallbackAdapter: true})ignored its own argument, handing back the real hardware adapter; it now always resolvesnull(this binding has no software adapter, and WebGPU §4.4.1 never allows hardware as a substitute). - A shader-creation error now surfaces at
createShaderModule, not later (fix, 2026-11-08, round 30, go-wasmcontainers report): reported on batch-shaders, their last red container case — a module using the reserved wordmetaas an identifier is genuinely invalid WGSL by spec, which every real engine refuses right atcreateShaderModule(captured by an open error scope, reported bygetCompilationInfo()with its real line/column), not only once a pipeline first tries to use it. Two questions kept separate: go-gfx/wgsl's ownValidate(is this real WGSL at all) runs independently ofwgslmsl.Translate(can THIS translator's own backend run it) — a construct this translator doesn't support yet but is genuinely valid WGSL (a switch statement, a pointer parameter) stays exactly as before, never reported as a shader-creation error, since a real browser would compile it fine. arrayLength(&resource)(fix, 2026-11-08, round 29, go-wasmcontainers report): reported after v0.45.6 cleared the entire ONNX lane (123/123) — their batch/batch-grant kernel's ownif (id.x < arrayLength(&out)) { ... }, the ONLY use of&in it (confirmed by reading their real source, not guessed — no atomics, noptr<function, T>parameters).callExprnow recognizesarrayLength(&X)for a tracked runtime-sized storage resource and emits its already-computed bounds-check element count directly — the same oneindexExpr's own clamp reads, correctly the bound sub-range's count. General&/pointer support stays out of scope until a real kernel needs it.- A
continueinsidefor/loopskipped the update/continuing block, hanging the kernel (fix, 2026-11-08, round 28, go-wasmcontainers report): reported after v0.45.5 (57 → 100 of 123 ONNX cases) — every remaining failure was a padded conv/pool/mnist-12 case hitting their host's own loop-budget guard.for's own lowering ranupdateAFTER the body, so a bare MSLcontinue(jumping straight back to thewhile(true)wrapper) skipped it outright — the loop variable never advanced, an infinite loop the instant that branch fired. Tried goto+labels first, confirmed wrong directly: Metal's own compiler refuses labeled statements outright. Fixed withdo { } while (false)+ a per-loop flag instead:continuebecomes a barebreak(exits only the wrapper, falls through to update); a realbreaksets the flag first, so the line after the wrapper also breaks the real, outer loop — without it, everybreakwould silently become a continue.loop { continuing { } }had the identical shape and got the identical fix. bitcast<T>(e)reinterprets bits, and a helper function may touch a resource directly (two fixes, 2026-11-08, round 27, go-wasmcontainers report): reported on their v0.45.4 retest (26 → 57 of 123 ONNX cases).bitcast<f32>(1u)was translated as a numeric conversion (float(x), giving1.0) instead of a bit reinterpretation (WGSL §17.2: the float whose bits equal 1, a tiny subnormal) — their Gemm/MatMul kernels decode an f32 alpha stored as raw bits in a u32 uniform this way, so every result came back ~1e9 too large. Fixed with Metal's ownas_type<T>(e), one emission covering scalar and same-arity vector bitcasts alike. A helper that directly touches a resource (round 26's own stated, bounded scope gap) is now threaded through as an extra MSL parameter — its own direct touches UNION every function it calls already needs, computed callee-before-caller — at both its own signature and every call site that reaches it, however many levels away.- wgslmsl translates calls to WGSL helper functions (fix, 2026-11-08,
round 26, go-wasmcontainers report, engine#391 follow-up): reported on
97 of their 123 ONNX cases — their nn kernels call small WGSL helpers for
index math and activation, and
Translaterefused any entry point reaching more than its own body. Every function the entry point transitively calls is now emitted as a real MSL function, callee-before- caller order (found along the way: go-gfx/wgsl's ownFuncObj.Calls/.Globalsare not populated outside an entry point — its own newermslpackage hits the identical gap and writes its own AST walker instead of trusting the field; wgslmsl now does the same). Scoped to pure value functions: a helper touching a<storage>/<uniform>resource directly is rejected, clear and named — resources are wired to the entry kernel's own buffer parameters and nowhere else. Considered and rejected: delegating to go-gfx/wgsl's own newmslpackage for WGSL-exact arithmetic semantics — real advantage, but splits helper-function math from entry-body math onto two correctness bars for no reported need. createBindGroupaccepts a GPUBufferBinding sub-range, not only the whole buffer (fix, 2026-11-08, round 25, go-wasmcontainers report): reported after v0.45.2 fixed their GPU hang —createBindGroup: a GPUBufferBinding size other than the whole buffer is not supported, blocking several of their batch/ONNX cases, all of which pack several logical buffers into one real allocation and bind each as its own sub-range. Two independent gaps, both confirmed needed by sabotaging each in isolation: go-macos/metal's ownEncoder.Bufferalways bound at offset 0 (fixed upstream, newEncoder.BufferAt, v0.7.0), and the bounds-check sizes buffer (wgslmsl's own per-binding array-length mechanism, since Metal performs no bounds checking of its own) was computed from the bound buffer's WHOLE size, not this binding's own sub-range. Fixed via a newgpuBoundBuffer{buf, offset, size}, validated against WebGPU §8.2.1/§4.2.1 (resides inside the buffer,minStorageBufferOffsetAlignment, size a multiple of 4) using the limits this binding already advertises.Worker.postMessageis genuinely asynchronous now, not a blocking round trip (fix, 2026-11-08, round 24, engine#388, M2/M3 follow-up): diagnosed jointly with go-wasmcontainers via aSIGQUITgoroutine dump on their own failingTestBatchComputeContainers.postToWorkerblocked the sender until the receiving Worker finished handling the posted message — a deliberate M2 trade-off ("a deterministic, testable round trip"), and a real deviation from the spec (postMessagemust be async: HTML §9.4.3). For a container whose entire life runs inside handling its first message, "wait until handled" became "wait until the container exits" — and a GPU call needing the sender's OWN event loop to run again (to resolve a promise, thenAtomics.notifythe waiting container) could never get it. A narrower fix (keep blocking, but also drain the sender's own timers while it waits) was tried and rejected: confirmed by a direct test that it breaks JS's own run-to-completion guarantee, letting asetTimeout/Promise.thencallback run in the middle of the currently-executing script that calledpostMessage. The real fix:postToWorkerclones (at post time, unchanged) and queues, then returns immediately; a reply, if any, arrives later through the same mechanism that already delivers a worker's unsolicited messages. A genuinely new data race this exposed —Options.Logis one func value every binder in a session shares, no longer serialized bypostToWorker's own block once workers run truly concurrently with main — is fixed with a newemitLoghelper, serialized by a session-wide mutex. go-wasmcontainers' own four required invariants (per-port FIFO order, clone-at-post-time semantics, a child'sAtomics.waitnot blocking its parent, unsolicited-message delivery while a child runs) are each covered by a test. Every existing test that assumed a worker's reply was visible immediately afterpostMessagereturned is restructured to poll instead — several of these were caught only by running the suite repeatedly (-count=25,-race -count=10), since they passed by scheduling luck on a single run.- A self-requeuing short-delay timer could starve an older, larger-delay
one indefinitely (fix, 2026-11-08, round 23, engine#342 fallout): found
while investigating a new go-wasmcontainers hang on v0.45.0, via a minimal
GOOS=jsprogram whose background goroutine keeps re-arming its owntime.Timer— exactlygo-gfx/webgpu's ownkickLoopshape, which exists so Go's "all goroutines asleep" deadlock detector doesn't fire while a sibling goroutine awaits a promise.drainTimers' own "ascending delay, ties FIFO" selection compared each due job's raw, static delay; a job that keeps re-queueing a fresh copy of itself never grows that field, so it always wins over an older job with a larger delay, for as many sweeps as it keeps re-arming. This narrower defect does not, by itself, explain go-wasmcontainers' own hang — their driver'sEnableWorkerStreaminggates every job's first firing by a real wall-clockreadyAt, under which mode a further reproduction points at the render's own live-loop/callback path instead, still open. EachtimerJobnow ages via askippedcounter, incremented whenever a sweep passes it over for a smaller-priority due job; selection comparesdelay - skipped, so a persistently-starved job's priority strictly falls until it wins — bounded and deterministic, not dependent on real wall-clock time (a wall-clock-based attempt turned out flaky: a tight unit-test loop doesn't reliably advance even one millisecond between sweeps). Blob,URL.createObjectURL/revokeObjectURL, andimportScriptsresolving ablob:URL (feature, 2026-11-08, round 22, engine#342 fallout): round 21's fix got go-wasmcontainers their first fully green container case — the whole chain running end to end, a guest starting, making its WASI calls, and exiting with its real code. The other five cases stop earlier, in their GPU host:go-gfx/webgpu's ownrecord_js.goloads its replay script inside a Worker asnew Blob([source], {type: "text/javascript"})->URL.createObjectURL(blob)->importScripts(thatBlobURL)->URL.revokeObjectURL(url), and none ofBlob/createObjectURL/ablob:-awareimportScriptsexisted in this engine at all. Scope, confirmed by their own report: aBlobbuilt from an array of STRING parts with atypeoption, and the File API's own "blob URL store" — notBufferSource/Blobparts or any other Blob method, none of which any confirmed caller touches.Blob's own content andURL's own static methods are scoped PER-BINDER (never a package-level registry) — ablob:URL is only ever valid in the environment that minted it, matching both a real browser and go-wasmcontainers' own shape (one Worker creates and consumes its own Blob entirely by itself).importScriptsnow resolves ablob:URL against that store directly, never through the HTTP fetch path.- A module's own shared memory now stays live for every agent even when
grown by the wasm
memory.growINSTRUCTION, not just the JS-level grow() method (fix, 2026-11-08, round 21, engine#342 fallout): round 20's fix cleared the exception-identity failure, and go-wasmcontainers'clock_time_getfailure from the SAME report turned out to be the exact same grow-visibility class of bug, one layer deeper — their real guest grows its OWN memory with thememory.growinstruction during Go runtime start-up, inside wazero, and the container worker that ran it never touches.bufferitself again afterward (it's blocked inAtomics.waitinside a host import), so round 20's "sync on grow() or a local read" fix never fired at all. wazero exposes no public "memory grew" hook, but its ownGrow, for a shared memory specifically, stores the new length with an ATOMIC operation explicitly to make it visible across threads — confirmed not just by reading that comment but by an isolated program hammeringSize()/Read()from one goroutine while another grows the SAME shared memory 200,000 times, clean undergo test -race. Fixed by giving the registered buffer a live reference to wazero's own memory and re-syncing from it, live, on every access — by this binder's own.bufferaccessor, by every OTHER agent's own reconstructed wrapper after a postMessage, and by everyAtomics.*op — rather than ever caching a snapshot. - A host import's own thrown exception propagates unchanged again, and a
module's exported shared memory now grows visibly across every agent
holding it (fix, 2026-11-08, round 20, engine#342 fallout): with round
19's fix, go-wasmcontainers' container guest fully RAN for the first
time — instantiated, memory accepted,
_startentered,fd_writeworking — then stopped, on two independent findings. First: their ownworker.jsthrowsnew Exit(code)from a host import when the guest callsproc_exit, expecting it (per the WebAssembly JS API: a host function's exception unwinds the wasm frames UNCHANGED, rethrown to the caller as the SAME value) to arrive intact for their owninstanceof Exitcheck — instead it arrived as a generic, wrappedRuntimeError, sincehostImportFuncpanicked with a brand-new Go error wrapping only the original exception's STRING, discarding the real value before wazero's own panic recovery (confirmed by reading it directly) ever got a chance to preserve it via%w. Fixed by panicking with the original error unchanged on the way in, and unwinding back to the original JS value (errors.As+exc.Value(), the SAME patternimportScriptsalready uses for a script-level exception) on the way out. Second, found the same round: a worker that grows its module's own exported shared memory and posts it to its creator — the creator saw the ORIGINAL byteLength, not the grown one, because the Go-level*sharedBufferidentityclone.gohands to other agents was rebuilt from scratch on every local size change instead of being updated in place, andgrow()never touched it at all. Fixed by creating it once and updating its own.datafield in place — a fresh, zero-copy view over wazero's own backing array, confirmed never relocated for a shared memory — eagerly, right whengrow()succeeds. WebAssembly.Memorynever satisfiedinstanceof WebAssembly.Memory(fix, 2026-11-08, round 19, engine#342 fallout): round 18's fix cleared the_startdouble-run, and go-wasmcontainers' next run still failed every container — their ownworker.jsvalidates a guest's exported memory with!(memory instanceof WebAssembly.Memory), which always fired even though the guest genuinely exported a real, present memory. Every Memory wrapper this engine builds (a module's own exported memory, and the standalone constructor, shared or not) stampedSymbol.toStringTagsoObject.prototype.toString.call()reported correctly, but NONE of the three ever calledSetPrototype— unlikeWebAssembly.Module/.Instance, which got a real prototype in round 7 specifically soinstanceofwould hold for THEM. Masked until now because every existing test checked the toStringTag, neverinstanceof, for Memory specifically. Fixed with a newwasmMemoryProto(mirroringwasmModuleProto/wasmInstanceProto) stamped onto all three construction sites, and a realWebAssembly.Memoryconstructor wired with its own.name/.prototype.WebAssembly.instantiate()no longer auto-runs an export merely named_start(fix, 2026-11-08, round 18, engine#342 fallout): round 17's fix cleared the hang, and go-wasmcontainers' very next run hit every container trapping 9 seconds in withLinkError: module[] function[_start] failed: wasm error: invalid table access, a repeatingruntime.write ← runtime.writeErrData ← ...wasm stack trace. Their own diagnosis named the mechanism precisely: wazero'sNewModuleConfigdefaults to running an exported_startautomatically (a WASI-CLI convenience, confirmed by reading wazero's own config.go), but the WebAssembly JS API's owninstantiate()only ever runs a module's internal, unexported start SECTION — never an exported function merely named_start. Their own realworker.jsinstantiates, posts the guest's shared memory to its creator, THEN callsinstance.exports._start()itself — exactly that division of responsibility. Running it a first time duringinstantiate(), before the memory message had even been sent, reached the guest's own firstfd_writewith no memory registered on the host side yet, and its own panic/error-write path recursed into the identical failure. Fixed by clearing wazero's default (.WithStartFunctions()with no arguments) on the oneInstantiateModulecall every JS-facing instantiate path funnels through.Worker.postMessageno longer blocks its own wake-up (fix, 2026-11-08, round 17, engine#342 fallout): round 16's fix cleared one failure in go-wasmcontainers'TestBatchComputeContainers, and the next run hung the full 10-minuteJSTimeoutinstead. Their own realruntime/browserpackage spawns one container Worker per guest and posts it a setup message; the container's ownworker.jsanswers by posting a "wake me" doorbell and then callingAtomics.waitSYNCHRONOUSLY, still inside that sameonmessagecall — valid, spec-compliant code, since a real browser'spostMessageis asynchronous and never blocks the sender. This engine's ownpostToWorkerwas, since round M2, deliberately synchronous/blocking instead (a documented trade-off for a deterministic test round trip) — which turned that exact valid pattern into a real deadlock: the creator'spostMessagecall couldn't return until the container finished, and the container could never finish without a notify only the (blocked) creator could send. Confirmed fast, before touching the real 10-minute repro: a new test reproducing the identical shape hung out its own 2-second deadman with the original code, every time. Fixed by turningpostToWorker's wait into a poll loop (the same 15ms cadence a worker's own event-loop ticker already uses) that keeps draining the creator's own pending worker inboxes — including a child's doorbell — the whole time it waits, instead of only once at the end.WebAssembly.ModulesurvivespostMessageacross workers (feature, 2026-11-08, round 16, engine#342 fallout): go-wasmcontainers compile each guest module ONCE in an engine worker and post the resultingModuleto a container worker, which instantiates it there — never the bytes, recompiling per-container at their scale being wasted work. Required sharing onewazero.Runtimeacross every worker (aCompiledModulecannot be instantiated under a different Runtime than the one that compiled it, confirmed directly), which in turn exposed two real collision pitfalls go-wasmcontainers flagged in advance and both confirmed directly, isolated, before touching production code: wazero refuses a second instance under a module name already live on a shared Runtime (fixed:instantiateWasmalways instantiates anonymously), and the temporary host-import modulebuildHostImportsregisters under the guest's own required namespace (e.g."env") collides the SAME way when two guests importing from"env"instantiate concurrently on the shared Runtime — confirmed that wazero binds an import at instantiate time, not by live name lookup, so closing that host module immediately after and reusing the freed name for the next guest doesn't disturb either guest's own already-bound calls; fixed with a new mutex (wasmInstantiateMu) serializing just that narrow window. New tests post a compiledModulethrough the main document to a second worker and confirm it instantiates and satisfiesinstanceof WebAssembly.Modulethere, and confirm two SEPARATE workers instantiating the SAME posted Module concurrently both succeed independently.- THE actual cause, independently confirmed against go-wasmcontainers'
own exact reproduction (fix, 2026-11-08, round 15): rounds 12-14
(below) were all real, independently-justified fixes, but none was
the crash — go-wasmcontainers eventually bisected it themselves to a
GPU-free, ~20-line repro and shared it, pointing at nested JS→wasm
reentry. Confirmed directly (not just by reasoning): copied their own
probe program and ran it against a locally-patched copy of this
engine — crashed under the current code, passed cleanly (20/20 rounds,
including real WebGPU calls) once
js/wasm.go's wazeroRuntimeConfigwas switched from the defaultNewRuntimeConfig()toNewRuntimeConfigInterpreter(). Root cause, found in wazero's own documentation: its default "Compiler" engine (active on amd64/arm64, the only two architectures this crash was ever seen on) JIT-compiles a module to native code that "uses the different stack than the calling Goroutine," and wazero's own doc comment names the exact consequence — "the signal handler might get invoked on the wazero's stack, which may cause a stack overflow," the literal "split stack overflow" signature seen throughout this whole investigation. The pure-Go Interpreter engine generates no native code and hence has no separate stack to desynchronize, removing the entire crash class. Measured, not just asserted: a 200M-iteration compute loop took 3.3s under the Compiler engine and 52.7s under the Interpreter engine (~16x), a real cost accepted directly in exchange for the crash. New testTestReentrantResumeAfterEventLoopResumereproduces the peer's own minimal pattern as a real Go-compiled-to-WASM fixture (this repo's first test to exercise the fullwasm_exec.js/go.runshape, not just a bare WASM module) — verified in both directions, including reproducing the EXACT same crash signature inside this repo's owngo testprocess when the fix was temporarily reverted. Follow-up found by reading, not a new bug: a heavier, nested-WASM-in-WASM workload started tripping the EXISTING heap-safety guard (heapGuardedContext, default ceilingbaseline + 768 MiB, from round 172's own real memory-exhaustion defect) — the Interpreter engine's own execution state needs more Go-heap for the same work than the Compiler engine's JIT-compiled code did. The guard cancels its OWN derived child context, not the caller's, which is why the caller's ownctx.Err()stayed nil throughout despite scripts being interrupted — resolved by raisingEngine.MaxJSHeapByteson the caller's side for workloads this heavy, not a code change here. - A real device-leak fix, independently real but not THE crash either
(fix, 2026-11-08, round 14): round 13's fix below was
also real, but go-wasmcontainers' own retest proved it wasn't their
crash either — same signatures, deterministic 6/6, and happening 8-12s
into the render, well before any
Session.Close.destroy()(js/webgpu.go) only resolved the JS-visiblelostpromise; it never called.Close()on the underlying*metal.Device. Their own workload loads ~123 ONNX models, each into its own fresh device, explicitly "destroying" it between models — sincedestroy()did nothing natively, all 123 stayed alive, crashing deep inside Apple's own Metal/Foundation frameworks once some OS-level resource ceiling was crossed. Live lldb debugging was attempted first but is fundamentally stuck in this sandboxed environment (everytask_for_pid-requiring tool hangs at 0% CPU); found by direct code reading instead, armed with the corrected "mid-render, not teardown" fact. Verified both directions via a test that cannot safely reproduce the real exhaustion itself (that would crash the test binary) but proves the mechanism: afterdestroy(), a subsequentcreateBuffer()must come back as a capturedGPUOutOfMemoryErrorthrough the existing error-scope path, proving the native device genuinely closed. - A real worker-GPU-teardown race, independently fixed but not THE
crash (fix, 2026-11-08, round 13): round 12's go-macos/metal bump was
real but go-wasmcontainers' own retest proved it did not fix their
crash — it made it deterministic (6/6). The actual bug found this
round:
js/worker.go'sstopWorkers(called fromSession.Close) signalled a worker to stop and IMMEDIATELY released its GPU devices on the MAIN goroutine, with no wait for the worker's own goroutine (the only one allowed to touch its binder, perjsWorker's own struct doc comment) to actually stop touching them — a worker whose own autonomously-scheduled timer callback was mid-flight inside a real, blocking Metal dispatch never even observed the stop signal, soClose()freed objects the worker was still using: a genuine concurrent use-after-free on native memory, invisible to-race.stopWorkersnow waits (bounded by the render's own deadman) for the worker to genuinely finish before touching anything it owns. Verified both directions: with the fix defeated,Close()returned in microseconds regardless of an in-flight ~600ms dispatch; with it in place,Close()correctly waited for the dispatch to finish. - go-macos/metal bumped to v0.6.1 (dependency, 2026-11-08, round 12):
closes an
objc.AutoreleasePoolgap in 8 functions (Default/Device.Close/Device.Compile/Library.Close/Library.Pipeline/Pipeline.Close/Device.NewBuffer/Buffer.Close) matching the exact signature of a native SIGSEGV go-wasmcontainers reported from concurrent per-Worker WebGPU calls — a goroutine migrating OS threads mid-Objective-C-call, the same documented bug class the SAME file's ownsubmit/Waitalready guard against. Not reproduced locally despite a dedicated 96,000-op concurrent stress test (consistent with the race being machine-dependent); the gap itself was real and is now closed at the library layer. See go-macos/metal#17. Options.MaxRequests/Engine.MaxRequests(feature, 2026-11-08, round 11): go-wasmcontainers' own real workload fetches on the order of 123 ONNX models plus their inputs/outputs per render — several hundred requests — which hit the previous hardcoded 100-request cap with no way to raise it. Now configurable, zero selects the unchanged default, the SAME conventionMaxResponseBytes/MaxWasmModuleBytes/MaxWasmMemoryBytesalready follow.- WebGPU error scopes now actually CAPTURE errors (fix, 2026-11-08,
round 10): round 9's
pushErrorScope/popErrorScopeonly got the push/pop balance right — every GPU error still threw synchronously, which go-wasmcontainers caught immediately: per WebGPU's own error model (§22),create*/submit/dispatchNEVER throw for a GPU content-validation failure (bad WGSL, a dispatch missing a bind group, an out-of-rangewriteBuffer) — the error is captured by the innermost open scope matching its class, or fires"uncapturederror"with none open, and the call itself still returns. A real, WebIDL-aware distinction is kept throughout: an actual argument-shape problem (missing descriptor, wrong type) still throws a synchronousTypeErrorregardless of any open scope — only GPU content validation is captured.GPUValidationError/GPUOutOfMemoryErrorobjects are real, with their own.constructor.name, matching exactly what go-gfx/webgpu reads off a popped error.createComputePipelineAsyncdeliberately bypasses scopes entirely and rejects its own promise directly on a compile failure — a documented special case in spec, not an oversight. - WebGPU: feature/limit iterables, error scopes, explicit bind group
layouts,
copyBufferToBuffer(feature, 2026-11-07, round 9): with Go'snet/httpfetch working (round 8), the next stop was WebGPU itself — go-gfx/webgpu panics readingadapter.features/device.featureswith.values()(GPUSupportedFeaturesissetlike<DOMString>:has/size/values/keys/entries/forEach/Symbol.iterator, and only the first two existed). A follow-up measurement, each item checked independently, narrowed the remaining gaps to exactly four: the features iterable,device.pushErrorScope/popErrorScope(push/pop balance was real and spec-correct from the start; actually CAPTURING an error instead of still throwing past the scope took one more day — see round 10 below, which go-wasmcontainers' own report caught immediately), theGPUShaderStagenamespace (never installed, unlike itsGPUBufferUsage/GPUMapModesiblings), anddevice.createBindGroupLayout(which also blockedcreatePipelineLayout/createComputePipelineAsync/ the mapAsync-readback item behind it). Explicit (non-"auto") pipeline layouts are now supported — confirmed needed because go-wasmcontainers' own batch compute path binds a loop-budget group next to the guest's own groups, which a single pipeline's inferred"auto"layout cannot express.GPUCommandEncoder.copyBufferToBufferis new too, with a real correctness subtlety worth citing: executing it as a plain Gocopy()the instant it is RECORDED (rather than in the order commands actually run on the GPU) could read stale, pre-dispatch bytes, since Metal's own command buffer only executes once adev.Runclosure returns — fixed by flushing (and blocking on) every pending dispatch before each copy, rather than batching an entire submission into onedev.Runcall. Headers.entries()/.keys()/.values(), andAbortController(feature, 2026-11-07, round 8): with Go actually running inside both workers (round 7), the next stop was its firstnet/httpfetch —net/http/roundtrip_js.gopanics onheaders.entries(), which did not exist, and constructsnew AbortController()before every fetch, which did not exist as a global at all.Headersnow implements the Fetch Standard's own pair-iterable (entries/keys/values, sorted and combined by name, plusSymbol.iteratordefaulting toentries()sofor...ofworks directly over aHeadersobject).AbortController's.signalis real (.aborted/.reason/.onabort/throwIfAborted); this engine'sfetch()is always synchronous by construction, so there is no genuinely in-flight request to cancel mid-flight — stated directly rather than faked — but a signal already aborted BEFOREfetch()is called now correctly rejects immediately with its reason.WebAssembly.Instance/.Moduleidentity, and a realperformance.now()(fix, 2026-11-07, round 7): round 6's fetch fix unblocked go-wasmcontainers' probe; the next run found both inside the Worker a guest runs in. Go's ownwasm_exec.jsdoesinstance instanceof WebAssembly.Instance, which threw outright (not justfalse) when neither global existed — now both are real constructors with their own.prototype, stamped onto everycompile()/instantiate()result; the deliberately-unsupported synchronous constructor forms now throw a clearTypeErrorinstead of not existing at all.performance.now()was a pure stub (return 0, for the main document too, not Worker-specific) masked by an existing test's own exact-string assertion pinning the wrong output — go-wasmcontainers' probe measured a 2,000,000-iteration loop between two calls giving the identical value. Fixed with a real per-bindertimeOrigin(time.Sincefrom it), sincewasm_exec.jsusesperformance.now()for Go's own monotonic clock — under the stub, elapsed time inside a guest never advanced AT ALL, however long it actually ran.fetch()no longer silently truncates a response over 8 MiB (fix, 2026-11-07, round 6): found investigating a SEPARATE go-wasmcontainers report that a ~10 MiB plain Go-compiled wasm module "did not report within 5 min and logged nothing at all" viainstantiateStreaming. A real ~9 MiB fixture compiled through wazero directly in 1.6s — not slow at all — so the real cause was upstream:io.ReadAll(io.LimitReader(..., 8<<20))returns a nil error whether a body was exactly 8 MiB or far larger and simply cut off, so ANY fetch over 8 MiB (a WASM module, an image, JSON) used to come back looking like a successful response carrying silently truncated, corrupt bytes.doRequestnow reads one byte past the limit and REJECTS outright, naming the real cause and the exact limit, if that extra byte arrived — never a truncated "success". The cap itself is now configurable (Engine.MaxResponseBytes/Options. MaxResponseBytes, zero selects the unchanged 8 MiB default), the SAME conventionMaxWasmModuleBytes/MaxWasmMemoryBytesalready follow, so a caller with legitimately larger resources raises it explicitly.- A module's own DECLARED shared memory (2026-11-07, round 5, engine#342
follow-up): resolves round 4's one open question below — asked
go-wasmcontainers directly whether their guest modules import memory or
declare it locally, and confirmed it's the latter (
wasmbin.LimitMemoryrewrites the module's own memory section to setmaximum+ the threads proposal'ssharedflag before compiling; the worker then readsinstance.exports.memoryand posts THAT). wazero's own public API has no way to ask whether a compiled module's memory is shared, so this engine answers it itself —moduleHasSharedMemorywalks the raw module bytes using the exact limits-flag rule wazero's own internal decoder applies (confirmed by reading its source, not guessed).wasmRTenables wazero'sexperimental.CoreFeaturesThreadsonly when cross-origin isolated — without it, wazero's own decoder rejects a shared-memory module outright, the SAME restriction a real browser enforces.instance.exports.memoryfor such a module now gets the identicalSharedArrayBuffer-classed, postMessage-able treatment round 4's standalone constructor already has. - Standalone
new WebAssembly.Memory(descriptor), includingshared: true(2026-11-07, round 4, engine#342 follow-up): the one still-failing line in go-wasmcontainers' own worker-globals probe, and confirmed by them as blocking every one of their guest containers (each constructs a sharedMemorybefore instantiating anything, then posts that SAME object to a second worker that reads/writes it directly while the guest blocks inAtomics.wait). Built on the existingSharedArrayBuffermachinery below: a growable shared buffer pre-allocates its fullmaximumup front and only ever RE-SLICES ongrow(never reallocates), so a write made through one agent's wrapper stays visible through another's — goja's ownArrayBufferhas no resize primitive, so a realloc-and-copy grow would have silently broken sharing for anyone already holding a reference. Gated on cross-origin isolation exactly likeSharedArrayBuffer/Atomics; postMessage-able as theMemoryobject itself (not just its.buffer). Not solved: wazero's own public API (confirmed by direct inspection) has no way to satisfy a module's memory IMPORT with host-provided bytes, so an actual guest.wasmmodule cannot yet use this Memory as its own linear memory that way — a real upstream limitation, stated rather than routed around. WebAssembly,location, nestedWorker, and a globalResponseconstructor (2026-11-07, round 3, engine#342 follow-up): go-wasmcontainers' own rewritten capability probe (checking every worker global at once rather than stopping at the firstReferenceError) named these precisely.WebAssembly/locationare the same recurring gap shape ascryptobefore them — no DOM dependency, now wired into a Worker's own scope;locationcorrectly reflects the WORKER's own script URL. NestedWorker(a worker creating its own child workers, confirmed needed by their engine) needed one real fix:stopWorkers' own cleanup now recurses, soSession.Close()stops a grandchild worker too, not just the immediate child — without it, a nested worker's own goroutine would leak past the session's lifetime.Responsewas genuinely missing as a global constructor everywhere, not just in workers (fetch()always built one internally, butnew Response(...)never existed) — fixed once, so both the main document and every Worker get it.SharedArrayBuffer+Atomics(2026-11-04, engine#343): real cross-VM shared memory for Worker's ownpostMessageboundary, gated on real cross-origin isolation (below) exactly like a real browser —new SharedArrayBuffer(n)wraps goja's own zero-copyArrayBuffer(registered so apostMessageof it, OR of a typed-array/DataViewVIEW over it, shares the SAME bytes instead of cloning them — the one deliberate exception to every other value's copy-not-share clone).Atomics.load/store/add/compareExchangework across the plain 8/16/32-bit integer TypedArray kinds;Atomics.wait/notify(restricted toInt32Array) give a real, blocking cross-goroutine wait/wake handshake —waitgenuinely parks the calling Worker's own goroutine until a matchingnotify(or its own timeout) wakes it. Deliberately restricted to a registered SharedArrayBuffer's own views — a plainArrayBufferthrows a namedTypeErrorrather than silently misbehaving, since cross-VM sharing is the one confirmed need Atomics exists for here.instanceof SharedArrayBuffer,Object.prototype.toString.call(x) === "[object SharedArrayBuffer]", andx.constructor === SharedArrayBufferall report correctly — on the receiving side of apostMessagetoo, not just where it was created — confirmed needed by go-wasmcontainers: real code (their own e2e probe included) branches oninstanceof SharedArrayBufferto choose a shared-memory path over a copy path. Remaining stated simplification:x instanceof ArrayBufferis alsotrue(the spec's two classes are siblings, not related by inheritance) — no confirmed need to separate them, since checkinginstanceof SharedArrayBufferfirst (the common idiom) already takes the correct branch regardless.window.crossOriginIsolated+ real COOP/COEP gating (2026-11-04):trueonly when the top document's own response actually carriedCross-Origin-Opener-Policy: same-originandCross-Origin-Embedder-Policy: require-corp(orcredentialless) —SharedArrayBuffer/Atomicsare not installed at all otherwise (window.SharedArrayBufferisundefined), matching a real browser exactly rather than a simplification: this engine is used as a test lane beside Chromium/Firefox/Safari, so a page missing these headers must see SAB denied here too, and a render can load cross-origin subresources into the same runtime regardless of its own process boundary, so the threat COOP/COEP guards against is real here as well.Engine.ForceCrossOriginIsolated(off by default) is the escape hatch for a host building its ownDocumentwith no real headers to check (tests, aFetcher). Workers inherit the creating page's own resolved value, per spec.setIntervalgenuinely repeats (2026-11-04): previously fired its callback once (clearTimeout/clearIntervalwere no-ops) — a simplification from before a page could usefully run long enough for repeating to matter. Now a realsetIntervalfires again at its own requested cadence (real wall-clock time, not an instant burst), andclearTimeout/clearIntervalactually cancel by id, including from within the interval's own callback (clearInterval(id)to stop yourself). Confirmed needed the momentEnableWorkerStreamingmade a genuinely long-running page a real thing: a page's ownsetInterval-based "is it done yet" poll previously ran exactly once, very early, reporting a stale snapshot forever after.- A one-shot
setTimeout's first firing also honours real wall-clock delay underEnableWorkerStreaming(2026-11-04): the fix above only covered a repeating interval's own re-firings; a plain, one-shotsetTimeout's first (and only) firing still fired at "the next opportunity" rather than after its real requested delay. This breaks the extremely common "safety-net timeout" idiom (race a near-immediate retry against a much-later give-up timer) the moment a page expects it to wait real time — confirmed directly: go-wasmcontainers' own e2e probe still failed after thesetIntervalfix, and a standalone reproduction of their exact page showed the whole render completing in under 4ms against their own 15-second safety net.EnableWorkerStreamingnow gates every timer job's first firing by its real delay, not just a repeating interval's own repeats. - Origin Private File System (2026-11-05,
navigator.storage.getDirectory(), engine#344):FileSystemDirectoryHandle/FileSystemFileHandle(getFileHandle/getDirectoryHandle/removeEntry) andFileSystemSyncAccessHandle(read/write/truncate/getSize/flush/close,[Exposed=DedicatedWorker]per spec — only reachable from inside a Worker). Off by default (Engine.OPFSRootempty): nothing is installed, zero real-disk writes a page could ever trigger. A host opts in by naming a real, durable directory; each origin gets its own SHA-256-hashed subdirectory under it, so two pages fetched by the sameEnginecan never see each other's tree. Every name is validated BEFORE any path join (rejects empty,.,..,/,\, NUL), making traversal outside the origin directory structurally impossible rather than filtered.Engine.MaxOPFSBytesbounds a single origin's total usage (zero selects a 50 MiB default, negative disables the guard); a write/truncate that would exceed it throwsQuotaExceededErrorwith no partial change.createSyncAccessHandletakes a real exclusive lock per file (NoModificationAllowedErroron a second concurrent open). wgslmsl: a pure-Go WGSL→MSL compute-shader translator (2026-11-05, engine#345, packagewgslmsl): the first piece of WebGPU compute support (M5). Parses and type-checks withgo-gfx/wgsl(a pure-Go, CTS-conformant WGSL frontend), then emits Metal Shading Language for a module's one@computeentry point — no Rust toolchain, no vendored binary blob (an earlier spike proved compilingnagatowasm32-unknown-unknownand running it through this engine's own WebAssembly support also works end to end, verified bit-for-bit against real Metal, but was superseded by this pure-Go path once go-gfx/wgsl turned out to need only a backend). Every array index is clamped against its real bound buffer's element count — Metal performs no bounds checking of its own, so this is a real security boundary: a page's own untrusted WGSL cannot read or write adjacent GPU memory through an out-of-range index, proven on real hardware byTestOutOfBoundsIndexIsClamped. Scoped deliberately to the realistic compute-kernel subset: one entry point reaching one function (no helper calls yet),storageresource arrays of scalar/vector element type, plus (2026-11-07) a bare<uniform>scalar/vector and a storageatomic<u32>/atomic<i32>with the ten common atomic built-ins (no textures/samplers/matrices/structs yet), the common control-flow statements and a fixed allow-list of built-in math functions — every construct outside that scope is a clear, named error, never a silent gap. Not yet wired tonavigator.gpu(next in M5).navigator.gpu/GPUAdapter/GPUDevice/GPUBuffer(2026-11-05, engine#345): WebGPU compute viago-macos/metal(pure Go, no cgo, cross-builds and cross-runs on every architecture this repo already targets, reporting no usable adapter anywhere but darwin). Off by default (Engine.EnableWebGPU) — same opt-in convention asOPFSRoot/ForceCrossOriginIsolated: a page getting compute access to the host's real GPU the instant it renders is a comparable capability grant to real disk access, so it is not on by default like a real browser. When on,requestAdapter()genuinely never rejects (resolvesnullwith no usable GPU, matching spec);GPUBuffer'smapAsync/getMappedRangereturn a real, zero-copy view over the buffer's own unified memory, andunmap/destroydetach every such view, per spec.GPUSupportedLimitsreports the spec's own documented default values (not yet queried from real hardware per-limit capability — stated honestly, not overclaimed). Shader modules and the rest of the compute pipeline (bind groups, command encoding,queue.submit()) follow below — M5 is now complete.GPUShaderModule/GPUComputePipeline/GPUBindGroup/GPUCommandEncoder/queue.submit()(2026-11-05, engine#345): the rest of the compute pipeline, closing out M5.createShaderModuletranslates viawgslmsleagerly but never throws — a bad shader still gets a usable module object, matching spec; the error surfaces fromgetCompilationInfo()or a clear throw fromcreateComputePipeline.layout: "auto"was the only supported form at first —wgslmslalready infers the real binding layout from the shader source itself — until round 9 (below) added explicitcreateBindGroupLayout/createPipelineLayout. A stated gap inherited fromgo-macos/metal, not from this binding's own choices:Dispatchpicks its own threadgroup shape from the hardware, with no way to force it to match a shader's declared@workgroup_size, solocal_invocation_id/workgroup_idmay not reflect the intended grouping — harmless for the common case (a kernel keyed purely offglobal_invocation_id), stated rather than silently risked otherwise.TestComputePipelineEndToEndis the roadmap's own final verification: a real compute shader created, compiled, bound and dispatched entirely through the publicnavigator.gpu-shaped API, output checked bit-for-bit against real Metal hardware.wgslmsl: uniform buffers and storageatomic<u32>/atomic<i32>(2026-11-07, engine#345 follow-up): go-wasmcontainers' own second confirmed need — every looping kernel in their batch path rewrites each loop to count against avar<uniform> budget: u32and sets avar<storage, read_write> flag: atomic<u32>viaatomicStorebefore breaking.atomicLoad/Store/Add/Sub/Max/Min/And/Or/Xor/Exchangemap 1:1 tometal_atomic's ownatomic_*_explicitfunctions (confirmed by compiling each against the real Metal compiler before this shipped, not assumed from documentation — Apple's own spec PDF exceeded the fetch tool's size limit). Confirmed directly, not just reasoned about:layout: "auto"already handles a kernel using@group(0)and an explicit@group(3)together correctly, with no changes needed. Still blocked, and stated:var<workgroup>/workgroupBarrier()/an exact@workgroup_size-matching dispatch (needed by their LLM kernels) —go-macos/metalv0.6.0 still has no way to pin an explicit threadgroup shape forDispatch.
Honest limits (not overclaimed)
<canvas>.getContext(...)is not implemented — a call throws (Object has no member 'getContext', confirmed live on stripe.com's own bundle). NoCanvasRenderingContext2D/WebGL surface at all, even an inert one.<picture>/srcset: a<source>'stypeattribute (skip one whose declared MIME type can't be decoded) and width/density descriptors (picking the best of severalsrcsetcandidates) are not evaluated — only the first comma-separated candidate of the firstmedia-matching<source>is ever taken. No confirmed trigger uses either.WebAssembly: no WASI (wasi_snapshot_preview1— stdio, filesystem, clocks) is wired, so a module assuming a WASI host (e.g. aGOOS=wasip1Go build, as opposed toGOOS=js) fails to instantiate with a LinkError naming the missing import.WebAssembly.Module/WebAssembly.Instanceexist as real constructors with their own.prototype(soinstanceofholds forcompile()/instantiate()results — round 7, 2026-11-07, confirmed needed by Go's ownwasm_exec.js), but calling either SYNCHRONOUSLY vianewstill throws aTypeError— no confirmed container uses that form over the asynccompile()/instantiate(). Table and Global imports/exports, and standaloneTableobjects are not implemented — no confirmed container needs any of them yet. Standalonenew WebAssembly.Memory(descriptor)(includingshared: true) IS implemented (2026-11-07, round 4), and so is a module's own LOCALLY DECLARED shared memory, read back viainstance.exports.memory(2026-11-07, round 5) — but passing aMemoryas a module's own memory IMPORT is not: wazero's public API has no way to satisfy that, a real upstream limitation (see round 4's own entry above), confirmed NOT to be on go-wasmcontainers' own critical path (round 5).compileStreaming/instantiateStreamingARE implemented (2026-11-03) but not via genuine incremental streaming — this engine's ownfetch()has no streaming body to begin with, so the Response argument is fully read before compiling, same as the plaincompile()/instantiate()forms.clamp()is only resolved forfont-size, which resolves%/vwEAGERLY at cascade time — a width-like property's own percentage still depends on a containing block only known at layout time, the same wallmin()/max()(unimplemented anywhere) andcalc()mixing a percentage with an absolute length already disclose above. Aclamp()term mixing a real percentage withvw/remin the SAME term is not attempted — no confirmed trigger uses it.calc()mixing any viewport unit —vh/vwor thesvh/lvh/dvh/svw/lvw/dvwvariants alike — with another term is not resolved; this predates round 193's unit-parsing fix and round 193 deliberately left it as is (calcParser.factor()already rejects any percentage-kind term outside its own dedicated%branch).clip-pathonly recognisespolygon()(with an optional leadingnonzero/evenoddfill-rule), resolved against the border box — the spec's default reference box. A<geometry-box>keyword,circle()/ellipse()/inset()/path(),round <length>vertex rounding, and aurl()reference to an SVG<clipPath>element (a different mechanism — see the SVG rasteriser's own clip handling) are not recognised and drop the whole declaration. Not extended to::before/::after. The polygon's edge has no antialiasing.- A dynamic
import(...), in ANY script (not only<script type="module">, which this engine's ownesbuildbundling pass already resolves statically) is rejected by goja's parser outright — confirmed directly against the pinned version. A runtime-computed import target (Astro's own universal client-hydration bootstrap, and likely other frameworks' lazy-loading patterns) can't be bundled away the way a static import can, since deferring resolution past build time is the whole point. No JS parser lives in this repo to patch. form.requestSubmit()fires the "submit" event only — no per-control constraint validation, and (likewindow.location.assign()/.reload()) no actual network submission or page navigation when the event is not cancelled, since this engine's single-document settle has no later page to navigate to.form.submit()fires no event at all, matching spec.customElements.define()only upgrades elements ALREADY in the document at the time it's called — a laterdocument.createElement(name)or a node inserted afterward is not upgraded. There is noobservedAttributes/attributeChangedCallback/connectedCallback/disconnectedCallback, and a constructor that itself callscustomElements.define()reentrantly is not handled. None of this is needed by the confirmed trigger (round 183); it is the HTML spec's own materially larger construction-stack/reaction-queue machinery.lab()/lch()(CIE Lab, not Oklab) are not recognised — a different conversion fromoklab()/oklch(), which this engine does support (round 182). Neither supports thenonecomponent keyword (CSS Color 4 §4.4); such a value drops the whole declaration, same as any other unsupported value here.conic-gradientis recognised but not painted.filterandmask-imageare modelled narrowly (see above): a mask is a singleurl()stretched over the box, with nomask-size/mask-position/mask-repeat. Transforms other than translate, rotate and scale (skew,matrix, any 3D function) are not supported, and the three supported ones never mix with each other, with filter/opacity/mask-image on the same element, or (for scale) with a negative, axis-flipping factor. SVG has no<filter>/<mask>/<pattern>/embedded<image>/<text>, and a per-page image budget caps very icon-heavy pages.::before/::aftergenerated content is synthesised for strings,counter()andcounters()(rounds 177, 187).attr(),url(), the quote keywords and the rest of the content grammar drop the declaration, same as any other unsupported value.@counter-styleis not supported: an unknown counter-style name is decimal, and an unknownlist-style-typekeyword leaves the declaration unapplied. Counter values are 32-bit; an increment that would overflow is not applied (WPT's expectation; the spec text says clamp). Abackground-image/mask-imageset on the pseudo-element is now fetched too (round 181), so an icon-only separator (e.g. MDN's breadcrumb chevron, a plain<li>::after{content:"";mask-image:url(...)}) renders — but only on an ordinary element: a form control's own::before/::afteris still invisible, since a<button>/<input>is laid out as an atomic box regardless of what its generated content would be (rounds 55-56).vertical-alignon a table cell modelstop,middleandbottom; thebaselinealignment across a row is approximated as top.- Rate-limited image hosts (Wikimedia's CDN, measured round 154) answer some requests with HTTP 429. The engine retries them, and the renders are pixel-identical, but each retry costs about 2 seconds of wall time on that page (measured round 155).
- Several pages render slower than Chrome (go.dev/blog, tailwindcss.com, pkg.go.dev): an open performance gap, not a fidelity one. The measured causes so far are the per-host request cap and the time spent on rate-limited responses.
- This is not a standards-complete browser and not "as good as Chromium". Measured mean windowed-SSIM across the ten bench pages is ≈ 0.66, with clear diminishing returns; the Wikipedia number (≈ 0.42) is JS-confounded and noisy. See the numbers below.
From bench/REPORT.md: windowed SSIM over the common top-left
region at 1024px width (1.0 = identical), pixel-diff %, and speed× =
chrome_ms / webengine_ms (>1 = webengine faster). Single run against live pages,
so the content of several pages moves between runs (see the notes in FIDELITY.md).
| URL | SSIM | pixdiff % | speed× | note |
|---|---|---|---|---|
| example.com/ | 0.831 | 5.6 | 18.3 | near-parity; its JS cross-fade is not modelled |
| en.wikipedia.org/wiki/Go | 0.417 | 30.3 | 1.6 | live article; JS and rate-limited thumbnails |
| pkg.go.dev/net/http | 0.716 | 11.3 | 0.8 | large computed page |
| go.dev/blog/ | 0.700 | 16.6 | 0.45 | slower than Chrome |
| react.dev/ | 0.725 | 33.0 | 1.3 | SPA; hydration fails, so the static fallback renders |
| news.ycombinator.com/ | 0.615 | 13.7 | 1.75 | table layout; rotating front page |
| developer.mozilla.org/…/CSS | 0.606 | 18.1 | 3.3 | docs layout |
| github.com/golang/go | 0.637 | 14.2 | 2.0 | live repository counters |
| tailwindcss.com/ | 0.724 | 12.2 | 0.5 | sponsor carousel rotates; slower |
| caniuse.com/ | 0.660 | 18.0 | 0.8 | data grid |
Mean SSIM over the ten pages is ≈ 0.66. Summed over the run, webengine took
26.1 s and headless Chrome 26.3 s, so the overall time is at parity, but the
per-page spread is wide: example.com and the static docs pages are much faster,
while go.dev, tailwindcss.com and the large pkg.go.dev page are slower. Re-run the
harness with cd bench && go run ./cmd/compare -urls urls.txt (needs a
Chrome/Chromium binary).
Ten pages is a small, curated sample. Two larger, less-curated corpora —
bench/urls-100.txt (100 pages, 100 distinct sites) and
bench/urls-500.txt (500 pages across 268 sites, built
from each site's own sitemap, ≤4 pages/site) — exist specifically to surface
what a small hand-picked sample won't: round 166's and round 167's crash
fixes (an unrecoverable stack overflow from a script-built DOM tree past 512
levels, and a CSS Grid panic on grid-row:1 / -1 with no explicit row
template) were both found this way, on smashingmagazine.com and lego.com
respectively — real pages no ten-page sample happened to include. Full
results: bench/REPORT-100.md,
bench/REPORT-500.md.
Mean windowed-SSIM is ≈ 0.53 on both (100-page median 0.55, 500-page
median 0.56) — lower than the curated ten-page set, which is expected: this
sample includes large, JS-heavy, highly dynamic sites (news homepages,
cloud-vendor marketing pages) the ten-page set was never meant to represent,
not a regression. 95 of 500 pages (19%) failed to render in BOTH webengine
and headless Chrome — dead URLs, DNS failures and timeouts, not an engine
gap; exactly one page failed in webengine alone (a transient DNS flake on the
measuring machine). Round 168 found and fixed a real, separate performance
bug this way too: several of the 500 pages' own origins (large organizations'
own CDNs/WAFs, confirmed via www.cs.cmu.edu) rendered 10-20× slower than
Chrome, traced to a font-face loading path that tried several candidate font
files in series instead of concurrently (see FIDELITY.md's own round-168
entry) — fixed, but the 500-page numbers above predate that fix and have not
yet been re-measured against it.
The engine fetches every resource a page names (images, stylesheets, fonts,
scripts and modules, fetch/XHR targets, form posts). It does not filter
destinations itself, so rendering an untrusted page from a machine with access to
private networks can reach internal addresses. If you embed the engine directly,
add a dial-time guard through Engine.Client that refuses loopback, private and
link-local addresses. browserproxy ships one (guard.go).
Since a renderer's whole job is turning arbitrary, untrusted page content into output, several unrelated kinds of pathological input have been found and capped (each confirmed live against a synthetic reproduction, not assumed):
- Decoding: raster images declaring more than 25 megapixels are refused before decode; every fetch is size-capped.
- Tree-depth recursion: a script-built DOM tree past 512 levels (bypassing
the HTML parser's own 512-level parse limit via
createElement+appendChild) used to crash the whole process with an unrecoverable Go stack overflow — not a panic,recover()cannot catch it — in the CSS cascade, layout,cloneNode/isEqualNode, and three CSS parsers (calc(),:not()/:has()/:host(),:is()/:where()/:matches(), the last of these via a shared output budget rather than a depth cap, since it explodes in alternative COUNT, not nesting depth). All are now capped at 512 (matching the parser's own bound) or a generous output budget. - Memory exhaustion: a single
innerHTMLassignment, or acloneNodecall repeated in a tight loop, could previously allocate tens of gigabytes of real memory from a few lines of ordinary-looking script — no deep nesting or large string needed.cloneNodeand the HTML→DOM conversion shared byParse/ParseFragment(and soinnerHTML/outerHTML/insertAdjacentHTML) now cap total node count per call; the rendered canvas itself is capped by total pixel count. - Unresponsive render, no malicious input required: an ordinary,
non-malicious page with several
background-repeatsections (found live on a real production site, not a crafted page) drove the paint phase to roughly a billion per-pixel calls throughimage.Image'sAt()— each one heap-allocating to box the returnedcolor.Colorand re-deriving a premultiplied value from straight storage — taking tens of seconds of CPU with the caller'scontext.Contextdeadline never consulted, because nothing in that per-pixel loop checked it. Fixed by normalising a background-image tile to a concrete*image.NRGBAonce, before it is stamped, instead of once per stamped pixel. - SVG rasterisation cost scales with output pixel area — a large,
gradient-filled decorative SVG (a background pattern, a hero
illustration) could previously cost several seconds regardless of how
modest its on-page footprint looked, with no ceiling as that area grows.
svgToBitmap(the screen-render path) now rasterises above a pixel-area budget at a reduced resolution and upscales to the requested size, the same way a mismatched-size background image or<img>already does. The separately exportedRasterizeSVG(used by the PDF/office vector export, which needs the exact size asked for at print density) is deliberately NOT capped. - WebAssembly (round 189) is arbitrary bytecode execution from an
untrusted page, audited as its own category: a module-size cap bounds
compile()'s input, a memory-growth cap bounds a hostile module's ownmemory.growloop, and every call is deadlined to the SAME wall-clock budget JS execution is already held to — a runaway module cannot outrun the page's own time budget any more than an infinite JS loop can. Both caps are configurable (Engine.MaxWasmModuleBytes/MaxWasmMemoryBytes, 2026-11-03) for a host that genuinely needs larger real modules, but always bounded by SOME finite quota — never unlimited, and clamped to the wasm32 4 GiB address-space ceiling regardless of what a caller requests. No WASI is wired, so a module cannot reach the filesystem, the network, or a clock through it.
See FIDELITY.md's rounds 166–178 for the full measured writeups. None of
these change how a well-formed page renders — only how a pathological one
degrades (truncated output, never a crash or unbounded allocation).
CGO_ENABLED=0 go build ./... # cgo-free build
CGO_ENABLED=0 go vet ./...
CGO_ENABLED=0 go test -short ./... # -short skips the live-network render
bash scripts/coverage-gate.sh # ratchet coverage gate (see below)
The pure logic (cascade/inheritance, line-breaker, box metrics, DOM, selector
engine) is asserted at exact geometry; committed golden PNGs — including offline
JS/dynamic/gradient/position/SVG fixtures with a DisableJS control — cover the
paint path. scripts/coverage-gate.sh enforces a ratchet coverage floor per
pure-logic package (css/layout/paint/dom/paginate: css 99.5%, layout,
paint and paginate 100%, dom 98.5%), which CI fails below and which is raised
(never lowered) toward 100% as the engine matures. The live-network
paths (root engine package, cmd/render) are excluded from the gate because
their coverage is not reproducible in CI. The bench/ fidelity harness is a
separate nested module (it pulls chromedp) and is not in the CGO=0 six-arch CI.
go.mod floor is go 1.27.2; cross-built for all six 64-bit Go targets.
- Landing: https://go-webengine.github.io/
- Documentation: https://go-webengine.github.io/docs/
- Fidelity report:
FIDELITY.md· Benchmark:bench/REPORT.md· Prior-art survey:SURVEY.md - Remote-browser service:
browserproxy
BSD-3-Clause — see LICENSE. Copyright (c) the go-webengine/engine
authors.
