Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions server/e2e/e2e_playwright_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
85 changes: 85 additions & 0 deletions server/e2e/e2e_webmcp_scratch_document_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
15 changes: 15 additions & 0 deletions server/e2e/testdata/webmcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
11 changes: 11 additions & 0 deletions server/e2e/testdata/webmcp/scratch-document-frame.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Scratch document modelContext in a frame</title>
</head>
<body>
<p>Scratch document read in a sandbox frame.</p>
<iframe srcdoc="<script>document.implementation.createHTMLDocument('').modelContext;</script>"></iframe>
</body>
</html>
15 changes: 15 additions & 0 deletions server/e2e/testdata/webmcp/scratch-document.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Scratch document modelContext</title>
<script>
// Reads modelContext on a document other than the frame's own, as
// sandboxing libraries do when they copy a scratch document's properties.
document.implementation.createHTMLDocument('').modelContext;
</script>
</head>
<body>
<p>Scratch document read in the top-level frame.</p>
</body>
</html>
8 changes: 6 additions & 2 deletions server/lib/webmcpclient/polyfill_bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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);
Expand Down
Loading