diff --git a/server/e2e/e2e_playwright_test.go b/server/e2e/e2e_playwright_test.go index 234dd313..a3f2b53e 100644 --- a/server/e2e/e2e_playwright_test.go +++ b/server/e2e/e2e_playwright_test.go @@ -275,6 +275,9 @@ func TestPlaywrightExecuteAPI(t *testing.T) { t.Run("WebMCPSlowPage", func(t *testing.T) { testCustomWebMCPSlowPage(t, ctx, client) }) + t.Run("WebMCPScratchDocument", func(t *testing.T) { + testWebMCPScratchDocument(t, ctx, c, client) + }) } func TestPlaywrightExecuteTimeoutReturnsPromptlyAndRecovers(t *testing.T) { diff --git a/server/e2e/e2e_webmcp_scratch_document_test.go b/server/e2e/e2e_webmcp_scratch_document_test.go new file mode 100644 index 00000000..095984b2 --- /dev/null +++ b/server/e2e/e2e_webmcp_scratch_document_test.go @@ -0,0 +1,85 @@ +package e2e + +import ( + "bytes" + "context" + "fmt" + "net/http" + "os" + "strings" + "testing" + + instanceoapi "github.com/kernel/kernel-images/server/lib/oapi" + "github.com/stretchr/testify/require" +) + +// Chromium terminates a renderer whose frame binds the WebMCP host from two +// documents (bad IPC reason 346). Pages that read modelContext on a scratch +// document, as some sandboxing libraries do at startup, have already made +// the first bind, so tool discovery must not make a second one. +const webMCPDuplicateBind = "Terminating renderer for bad IPC message, reason 346" + +// testWebMCPScratchDocument lists tools once on pages that already read +// modelContext on a scratch document and asserts the renderer survives. It +// runs last on the shared container: a killed foreground tab stalls +// /playwright/execute, so a regression must not take later subtests with it. +func testWebMCPScratchDocument(t *testing.T, ctx context.Context, c *TestContainer, client *instanceoapi.ClientWithResponses) { + t.Helper() + + for _, test := range []struct { + name string + file string + }{ + {name: "top_level", file: "scratch-document.html"}, + {name: "sandbox_frame", file: "scratch-document-frame.html"}, + } { + t.Run(test.name, func(t *testing.T) { + fixture, err := os.ReadFile("testdata/webmcp/" + test.file) + require.NoError(t, err) + written, err := client.WriteFileWithBodyWithResponse(ctx, + &instanceoapi.WriteFileParams{Path: "/tmp/" + test.file}, "text/html", bytes.NewReader(fixture)) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, written.StatusCode(), "%s", written.Body) + + pageURL := "file:///tmp/" + test.file + var frames int + executeWebMCPPlaywright(t, ctx, client, fmt.Sprintf(` + await page.goto(%q, { waitUntil: 'load' }); + await page.evaluate(() => { window.__alive = true; }); + return page.frames().length; + `, pageURL), &frames) + t.Logf("%s loaded with %d frames", pageURL, frames) + + kills := strings.Count(chromiumLog(t, ctx, c), webMCPDuplicateBind) + + rsp, err := client.GetWebMCPToolsWithResponse(ctx, &instanceoapi.GetWebMCPToolsParams{}) + require.NoError(t, err) + require.Equal(t, http.StatusOK, rsp.StatusCode(), "%s", rsp.Body) + t.Logf("GET /webmcp/tools: %s", rsp.Body) + + // Chromium logs the kill before the listing returns. + log := chromiumLog(t, ctx, c) + require.Equal(t, kills, strings.Count(log, webMCPDuplicateBind), + "GET /webmcp/tools killed the renderer:\n%s", tailLines(log, 20)) + + var alive bool + executeWebMCPPlaywright(t, ctx, client, `return page.evaluate(() => window.__alive === true);`, &alive) + require.True(t, alive, "the page reloaded during GET /webmcp/tools") + }) + } +} + +func chromiumLog(t *testing.T, ctx context.Context, c *TestContainer) string { + t.Helper() + log, err := execCombinedOutputWithClient(ctx, c, "cat", []string{"/var/log/supervisord/chromium"}) + require.NoError(t, err) + return log +} + +func tailLines(s string, n int) string { + lines := strings.Split(strings.TrimRight(s, "\n"), "\n") + if len(lines) > n { + lines = lines[len(lines)-n:] + } + return strings.Join(lines, "\n") +} diff --git a/server/e2e/testdata/webmcp/README.md b/server/e2e/testdata/webmcp/README.md index abf75398..593ef541 100644 --- a/server/e2e/testdata/webmcp/README.md +++ b/server/e2e/testdata/webmcp/README.md @@ -26,3 +26,18 @@ registry of its own. Run with: ```bash GOFLAGS='-run=TestPlaywrightExecuteAPI/WebMCPPolyfill -count=1' make test-e2e ``` + +# Scratch-document fixtures + +`scratch-document.html` reads `modelContext` on a `document.implementation.createHTMLDocument()` +document at load, the way some sandboxing libraries copy a scratch document's properties. +`scratch-document-frame.html` does the same read inside a `srcdoc` child frame. Chromium allows +one WebMCP host bind per frame, so a later `document.modelContext` read on the frame's real +document terminates the renderer (`bad IPC message, reason 346`). The test lists tools once on +each page and asserts the Chromium log has no new kill and the page kept its state. It runs as +the last `TestPlaywrightExecuteAPI` subtest because a killed foreground tab also stalls +`/playwright/execute`. + +```bash +GOFLAGS='-run=TestPlaywrightExecuteAPI/WebMCPScratchDocument -count=1' make test-e2e +``` diff --git a/server/e2e/testdata/webmcp/scratch-document-frame.html b/server/e2e/testdata/webmcp/scratch-document-frame.html new file mode 100644 index 00000000..fa2d232b --- /dev/null +++ b/server/e2e/testdata/webmcp/scratch-document-frame.html @@ -0,0 +1,11 @@ + + + + + Scratch document modelContext in a frame + + +

Scratch document read in a sandbox frame.

+ + + diff --git a/server/e2e/testdata/webmcp/scratch-document.html b/server/e2e/testdata/webmcp/scratch-document.html new file mode 100644 index 00000000..6f1b87ce --- /dev/null +++ b/server/e2e/testdata/webmcp/scratch-document.html @@ -0,0 +1,15 @@ + + + + + Scratch document modelContext + + + +

Scratch document read in the top-level frame.

+ + diff --git a/server/lib/webmcpclient/polyfill_bridge.js b/server/lib/webmcpclient/polyfill_bridge.js index 9e2af9e5..d3865942 100644 --- a/server/lib/webmcpclient/polyfill_bridge.js +++ b/server/lib/webmcpclient/polyfill_bridge.js @@ -52,7 +52,7 @@ function () { return null; } if (!isObject(context) && !isFunction(context)) return null; - if (isNative(context) || context === nativeContext()) return null; + if (isNative(context)) return null; return context; } @@ -291,8 +291,12 @@ function () { bridged.clear(); bridgedDocument = document; } - const native = nativeContext(); + // Reading document.modelContext binds the frame's WebMCP host, and + // Chromium kills a renderer whose frame already bound it from another + // document (for example, a scratch document). Only read it + // when there is a polyfill to bridge. const context = polyfill(); + const native = context ? nativeContext() : null; const desired = native && context ? await desiredTools(context) : new Map(); if (desired.size > 0) { for (const name of await nativeNames(native)) desired.delete(name);