feat(engine): engine protocol for out-of-process engines
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-Lengthframing, 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 ordinaryEngineHandle, so nothing pastcreateEngineSessionchanges. It opens onerunconnection per target forprepare/finishand oneworkerconnection forinit..dispose, mirroring where the harness calls an engine today.serveEngine(server.ts) serves anydefineEnginehandle;describeEnginederives 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 asschema/engine-manifest-v1.schema.jsonfor 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
appPathfrom 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
defineEnginetarget is served in process overloopbackTransport, so every suite crosses the wire and a contract change that cannot fails everywhere. AconnectEnginehandle is left as declared. Each engine gets one client, so ownership checks that compare handles (MCPENGINE_IN_USE, hosted workers) still work. disposereaches the engine onceinitwas called. A worker that never initialized has no connection, so its engine gets nodispose;Engine.disposedocs say so.- Docs: new
reference/engine-protocolpage.
Verified
Ran it locally: yes. Branch against main, built packages, same machine:
| Suite | main | branch |
|---|---|---|
pnpm test (all packages, e2e integration included) | - | all pass (e2e 3628, web 574, mobile 343, rest) |
| testbed | 79/79 | 79/79 |
| web-benchmark | 91/91 | 91/91 |
| testbed agent | 29/29 | 29/29 |
| web-benchmark agent | 33/33 | 33/33 |
| mobile benchmark Android | 55/55, 403s | 55/55, 403s |
| mobile benchmark iOS | 42/55 | 53/55 |
| mobile agent Android | 16/20 | 20/20 |
- iOS failures on both are local (
setOrientationfails 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 plainError, aTypeError, a string, and a retryableNODE_STALEreports 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.
observeover 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.