From f8de25500cec67d0edf17dbfdc3cabf4a61f4895 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:59:39 +0000 Subject: [PATCH 1/2] Keep tabs alive when WebMCP discovery runs on scratch-document pages The polyfill bridge read document.modelContext in every frame on every tool listing. On a frame that had already read modelContext on another document, that read was a second WebMCP host bind and Chromium killed the renderer. Read it only when the page has a navigator.modelContext polyfill to bridge, and add an e2e test that lists tools on such pages and asserts the renderer survives. --- .../e2e/e2e_webmcp_scratch_document_test.go | 106 ++++++++++++++++++ server/e2e/testdata/webmcp/README.md | 14 +++ .../webmcp/scratch-document-frame.html | 11 ++ .../e2e/testdata/webmcp/scratch-document.html | 15 +++ server/lib/webmcpclient/polyfill_bridge.js | 8 +- 5 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 server/e2e/e2e_webmcp_scratch_document_test.go create mode 100644 server/e2e/testdata/webmcp/scratch-document-frame.html create mode 100644 server/e2e/testdata/webmcp/scratch-document.html 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..ce6ba852 --- /dev/null +++ b/server/e2e/e2e_webmcp_scratch_document_test.go @@ -0,0 +1,106 @@ +package e2e + +import ( + "bytes" + "context" + "fmt" + "net/http" + "os" + "os/exec" + "strings" + "testing" + "time" + + 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" + +func TestWebMCPDiscoveryKeepsScratchDocumentPagesAlive(t *testing.T) { + t.Parallel() + + if _, err := exec.LookPath("docker"); err != nil { + t.Skipf("docker not available: %v", err) + } + + 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) { + t.Parallel() + + // A killed foreground tab also stalls /playwright/execute, so each + // case gets its own browser. + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + c := NewTestContainer(t, headlessImage) + require.NoError(t, c.Start(ctx, ContainerConfig{ + Env: map[string]string{ + "CHROMIUM_FLAGS": "--enable-features=WebMCPTesting,DevToolsWebMCPSupport", + }, + }), "failed to start container") + defer c.Stop(ctx) + + require.NoError(t, c.WaitReady(ctx), "api not ready") + require.NoError(t, c.WaitBrowser(ctx), "browser not ready") + + client, err := c.APIClient() + require.NoError(t, err) + + 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) + + 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.False(t, strings.Contains(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..73cc8117 100644 --- a/server/e2e/testdata/webmcp/README.md +++ b/server/e2e/testdata/webmcp/README.md @@ -26,3 +26,17 @@ 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 such kill and the page kept its state. Each case +runs in its own container because a killed foreground tab also stalls `/playwright/execute`. + +```bash +GOFLAGS='-run=TestWebMCPDiscoveryKeepsScratchDocumentPagesAlive -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); From 12ea5cb3240fe6d8e367b48c98b97d8af24f350e Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:06:06 +0000 Subject: [PATCH 2/2] Run the WebMCP scratch-document test on the shared Playwright container --- server/e2e/e2e_playwright_test.go | 3 ++ .../e2e/e2e_webmcp_scratch_document_test.go | 39 +++++-------------- server/e2e/testdata/webmcp/README.md | 7 ++-- 3 files changed, 16 insertions(+), 33 deletions(-) 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 index ce6ba852..095984b2 100644 --- a/server/e2e/e2e_webmcp_scratch_document_test.go +++ b/server/e2e/e2e_webmcp_scratch_document_test.go @@ -6,10 +6,8 @@ import ( "fmt" "net/http" "os" - "os/exec" "strings" "testing" - "time" instanceoapi "github.com/kernel/kernel-images/server/lib/oapi" "github.com/stretchr/testify/require" @@ -21,12 +19,12 @@ import ( // the first bind, so tool discovery must not make a second one. const webMCPDuplicateBind = "Terminating renderer for bad IPC message, reason 346" -func TestWebMCPDiscoveryKeepsScratchDocumentPagesAlive(t *testing.T) { - t.Parallel() - - if _, err := exec.LookPath("docker"); err != nil { - t.Skipf("docker not available: %v", err) - } +// 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 @@ -36,27 +34,6 @@ func TestWebMCPDiscoveryKeepsScratchDocumentPagesAlive(t *testing.T) { {name: "sandbox_frame", file: "scratch-document-frame.html"}, } { t.Run(test.name, func(t *testing.T) { - t.Parallel() - - // A killed foreground tab also stalls /playwright/execute, so each - // case gets its own browser. - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer cancel() - - c := NewTestContainer(t, headlessImage) - require.NoError(t, c.Start(ctx, ContainerConfig{ - Env: map[string]string{ - "CHROMIUM_FLAGS": "--enable-features=WebMCPTesting,DevToolsWebMCPSupport", - }, - }), "failed to start container") - defer c.Stop(ctx) - - require.NoError(t, c.WaitReady(ctx), "api not ready") - require.NoError(t, c.WaitBrowser(ctx), "browser not ready") - - client, err := c.APIClient() - require.NoError(t, err) - fixture, err := os.ReadFile("testdata/webmcp/" + test.file) require.NoError(t, err) written, err := client.WriteFileWithBodyWithResponse(ctx, @@ -73,6 +50,8 @@ func TestWebMCPDiscoveryKeepsScratchDocumentPagesAlive(t *testing.T) { `, 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) @@ -80,7 +59,7 @@ func TestWebMCPDiscoveryKeepsScratchDocumentPagesAlive(t *testing.T) { // Chromium logs the kill before the listing returns. log := chromiumLog(t, ctx, c) - require.False(t, strings.Contains(log, webMCPDuplicateBind), + require.Equal(t, kills, strings.Count(log, webMCPDuplicateBind), "GET /webmcp/tools killed the renderer:\n%s", tailLines(log, 20)) var alive bool diff --git a/server/e2e/testdata/webmcp/README.md b/server/e2e/testdata/webmcp/README.md index 73cc8117..593ef541 100644 --- a/server/e2e/testdata/webmcp/README.md +++ b/server/e2e/testdata/webmcp/README.md @@ -34,9 +34,10 @@ document at load, the way some sandboxing libraries copy a scratch document's pr `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 such kill and the page kept its state. Each case -runs in its own container because a killed foreground tab also stalls `/playwright/execute`. +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=TestWebMCPDiscoveryKeepsScratchDocumentPagesAlive -count=1' make test-e2e +GOFLAGS='-run=TestPlaywrightExecuteAPI/WebMCPScratchDocument -count=1' make test-e2e ```