feat(engine): engine protocol for out-of-process engines

OK
10h ago
+3052-13

The engine contract can now cross a process boundary. An engine runs in a child process, on another machine, or on a device, written in any language, and the harness drives it exactly as it drives one built with defineEngine. Every engine now runs through it: web and mobile are served in process over loopbackTransport, so the harness drives every engine one way. Two experiments (a Java Android engine, 2.4x faster than mobile() on agent-device, and a Swift iOS engine) passed the whole mobile benchmark over it with no protocol change; they are closed, kept as branches.

The protocol

The Language Server Protocol, applied to engines:

  • Same base layer: JSON-RPC 2.0 over Content-Length framing, requests and notifications both ways.
  • Same lifecycle: initialize / initialized / shutdown / exit.
  • A server announces a manifest the way a language server announces capabilities.

Each engine member is one request (engine/observe, engine/perform, lifecycle/init, ...) whose params are its arguments as JSON. Callbacks into the harness (appLog, screen, resolveSecret, log) are server-to-client messages. An AbortSignal crosses as an id plus $/abort, with its reason, so a TestTimeoutError reason reaches the engine as it would in process. Errors keep their code, retryability, and category, so the harness classifies a remote failure exactly as a local one.

flowchart LR
  H[harness: createEngineSession] --> C[connectEngine<br/>EngineHandle]
  C -- JSON-RPC --> T{transport}
  T -- loopback --> S1[serveEngine web or mobile, same process]
  T -- spawn stdio --> S2[serveEngine in a child process]
  T -- socket --> S3[server on another machine]
  T -- adb forward --> S4[Java server on an Android device]
import { connectEngine, spawnTransport } from 'e2e/engine';

engine: connectEngine({
  manifest: { name: 'kiosk', version: '1.0.0', spiVersion: 1, methods: ['engine/observe', 'engine/perform'], actions: ['tap'] },
  transport: spawnTransport({ command: './kiosk-engine' }),
  resolveLocators: true, // the server only produces a tree; locators resolve on the client
});
  • connectEngine (packages/e2e/src/engine/protocol/client.ts) is an ordinary EngineHandle, so nothing past createEngineSession changes. It opens one run connection per target for prepare/finish and one worker connection for init..dispose, mirroring where the harness calls an engine today.
  • serveEngine (server.ts) serves any defineEngine handle; describeEngine derives its manifest.
  • The manifest is declared at config load, because the harness needs capabilities synchronously there. The server's own manifest must match at initialize. Its JSON Schema ships as schema/engine-manifest-v1.schema.json for servers in other languages.
  • A server that cannot write the host's artifact directory (one on a device) returns a screenshot's bytes, and the client writes the file. Transports receive the target's app, so a device transport can install appPath from the project.
  • Fixtures, validateApp, and declared secrets stay on the client: they hold functions. A fixture reaches the server through the handle's own members.
  • Every defineEngine target is served in process over loopbackTransport, so every suite crosses the wire and a contract change that cannot fails everywhere. A connectEngine handle is left as declared. Each engine gets one client, so ownership checks that compare handles (MCP ENGINE_IN_USE, hosted workers) still work.
  • dispose reaches the engine once init was called. A worker that never initialized has no connection, so its engine gets no dispose; Engine.dispose docs say so.
  • Docs: new reference/engine-protocol page.

Verified

Ran it locally: yes. Branch against main, built packages, same machine:

Suitemainbranch
pnpm test (all packages, e2e integration included)-all pass (e2e 3628, web 574, mobile 343, rest)
testbed79/7979/79
web-benchmark91/9191/91
testbed agent29/2929/29
web-benchmark agent33/3333/33
mobile benchmark Android55/55, 403s55/55, 403s
mobile benchmark iOS42/5553/55
mobile agent Android16/2020/20
  • iOS failures on both are local (setOrientation fails here on every run, agent-device runner and device-match errors); the two branch failures fail on main too on rerun. Mobile agent failures on main are agent and device flakes.
  • Errors: an engine throwing EngineError, TestError, a plain Error, a TypeError, a string, and a retryable NODE_STALE reports the same code, category, message, and trace page both ways. One difference: an engine error thrown from a project file now points at the test line, not the engine's throw site. Engines from packages already pointed at the test line.
  • Overhead: testbed 10 alternating runs, main 19.8-22.4s, branch 19.8-27.4s, no consistent gap. observe over the protocol costs its JSON round trip: 0.14 ms for 200 nodes, 1.6 ms for 2000, 10-28 ms for a 10000-node (1.4 MB) tree.
  • One unexplained testbed failure on the branch in 29 runs (output not kept); 22 further runs, 10 of them under full CPU stress, passed on both trees.
  • A real child process: the unit tests cover a server in its own process and a crash mid-call that surfaces the server's stderr. A hand-written JSON-RPC server, with no serveEngine, stands in for one in another language.