From 0152521cb955568ffa85137f5dda9c25b830e807 Mon Sep 17 00:00:00 2001 From: Vojtech Novak Date: Tue, 27 Oct 2026 00:38:26 +0100 Subject: [PATCH] =?UTF-8?q?[agent-cli]=20The=20bundle=20check=20sends=20it?= =?UTF-8?q?s=20HEAD=20to=20the=20dev=20server=20it=20was=20given,=20never?= =?UTF-8?q?=20to=20the=20tunnel=20host=20(llp/0010=20=C2=A7Other=20gates)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On an EAS Simulator session every runtime read (runtime:tree, tap, type, reload, smoke) answered "No target found" although Metro listed the app (dogfood night 3, N3-03). The entry-bundle check HEADed the manifest's launchAsset.url, which under --tunnel names the tunnel host, so the HEAD went out through the Expo tunnel and back in. Measured on a local `expo start --tunnel` with no device (Expo CLI 58.1.13, @expo/ws-tunnel 2.0.1): the relay forwards that HEAD to the local tunnel client as a GET, aborts it once the headers are out, and the client answers the abort with a RequestAbort message the relay rejects ("[Server Disconnect] Invalid message type: 2", close 1007). The relay then drops the whole tunnel websocket and every websocket proxied over it: the app's inspector socket, HMR, /message. The app reconnects later; until then the target is listed and does not answer the probe. The check now keeps the path and query the manifest or the web page names and sends the HEAD to the dev server it was given. bundle.url is that local URL. llp/0010 states the rule; llp/0022 says the live-cloud suite proves the public origin elsewhere. Red before: cloud-simulator-test "restarts the app and then deep-links the route, and never closes it" (bundle checked: false on main), bundleCheck-test "should send the HEAD to the dev server given a launch asset on the tunnel host" and "... when the page names another host". Live, no device: with main's code the CLI's own check closed two websockets held through the tunnel (1007); with this change, four runs left them open and the tunnel log showed no bundle request. Co-Authored-By: Claude Fable 5.1 --- packages/@expo/agent-cli/CHANGELOG.md | 1 + .../e2e-live/__tests__/live-cloud-test.ts | 5 ++- .../e2e/__tests__/cloud-simulator-test.ts | 3 ++ .../llp/0010-agent-conventions.rfc.md | 2 +- .../agent-cli/llp/0022-live-tier.plan.md | 4 +- .../src/runtime/__tests__/bundleCheck-test.ts | 45 ++++++++++++++----- .../agent-cli/src/runtime/bundleCheck.ts | 45 ++++++++++++++----- 7 files changed, 81 insertions(+), 24 deletions(-) diff --git a/packages/@expo/agent-cli/CHANGELOG.md b/packages/@expo/agent-cli/CHANGELOG.md index 1b141a1a8..2c2baa4a8 100644 --- a/packages/@expo/agent-cli/CHANGELOG.md +++ b/packages/@expo/agent-cli/CHANGELOG.md @@ -36,6 +36,7 @@ ### 🐛 Bug fixes +- The entry-bundle check that runs before `runtime:tree`, `runtime:tap`, `runtime:type`, `runtime:reload` and `smoke` sends its HEAD to the dev server it was given, never to the tunnel host the manifest names. Through the Expo tunnel that HEAD closed every websocket on the tunnel, the app's debugger socket too, so on a dev server started with `--tunnel` (every EAS Simulator session) every runtime read answered "No target found". ([#197](https://github.com/expo/expo-agent-cli/pull/197) by [@vonovak](https://github.com/vonovak)) - `dev --detach --wait-ready` rejects another project's Metro and follows its own child's port changes within the original timeout. The same readiness check applies when reusing a running server. - Discovery skips a dev server whose `/status` names another project root, on the scanned ports and on the port `start.log` names, and reports the foreign servers in `status` when nothing else answered. A `/status` that does not answer within 3 s is not accepted. The dev-server lock never publishes a port that serves another project, named on the command line or not. ([#132](https://github.com/expo/expo-agent-cli/pull/132) by [@vonovak](https://github.com/vonovak)) - `status --explain`, the `--eas` device commands, and a failed `eas` step of `dev` no longer quote the first line of the EAS CLI's "project not configured" message with the `eas init` forms cut off. They say the project is not linked to an EAS project and name `npx --yes eas-cli@latest init --account --non-interactive`, with the account filled in when EAS listed one — the answer `deploy` already gave. An unlinked project is its own needs-human scenario, `eas-project-unlinked` (`EAS_PROJECT_NOT_LINKED`), where the generic `eas-prompt` row used to answer for it and tell the caller to answer a question in a terminal. ([#38](https://github.com/expo/expo-agent-cli/pull/38) by [@kudo](https://github.com/kudo)) diff --git a/packages/@expo/agent-cli/e2e-live/__tests__/live-cloud-test.ts b/packages/@expo/agent-cli/e2e-live/__tests__/live-cloud-test.ts index c4d0d7754..7df2cbd43 100644 --- a/packages/@expo/agent-cli/e2e-live/__tests__/live-cloud-test.ts +++ b/packages/@expo/agent-cli/e2e-live/__tests__/live-cloud-test.ts @@ -698,7 +698,10 @@ describeLive('live-cloud', gate)('live-cloud: an EAS Simulator session, on expo- 'dev-server-bundle', ]).toContain(report.verifiedBy); expect(report.bundle.ok).toBe(true); - expect(report.bundle.url).toContain(publicHost); + // The check stays on the local dev server and never touches the tunnel (llp/0010 §Other gates). + // The public origin is proven by the `/status` curl above and, in expo-go mode, by + // `navigate`; in dev-build mode the curl is the only proof. + expect(report.bundle.url).toContain(`http://127.0.0.1:${port}/`); // The two lists, reported side by side because they can disagree here (llp/0005 §What proves a reload // question). `commandSocketClients` has to be a number rather than null: "nobody asked" and "nobody diff --git a/packages/@expo/agent-cli/e2e/__tests__/cloud-simulator-test.ts b/packages/@expo/agent-cli/e2e/__tests__/cloud-simulator-test.ts index 70db6936c..022dc846a 100644 --- a/packages/@expo/agent-cli/e2e/__tests__/cloud-simulator-test.ts +++ b/packages/@expo/agent-cli/e2e/__tests__/cloud-simulator-test.ts @@ -659,6 +659,9 @@ describe('@expo/agent-cli runtime:reload --eas', () => { // Watched and not seen: this project has a captured dev server log, and the stub never // writes a `Bundled` line into it. expect(report.bundlesAfterReload.observed).toBe(false); + // The manifest names a tunnel host; the bundle check asks this stub, so it compiles here. + expect(report.bundle).toMatchObject({ checked: true, ok: true }); + expect(new URL(report.bundle.url).origin).toBe(new URL(stub.url).origin); expect(result.exitCode).toBe(22); const execs = easInvocations(projectRoot).filter((argv) => argv[0] === 'simulator:exec'); diff --git a/packages/@expo/agent-cli/llp/0010-agent-conventions.rfc.md b/packages/@expo/agent-cli/llp/0010-agent-conventions.rfc.md index 3902edd48..258dd41ed 100644 --- a/packages/@expo/agent-cli/llp/0010-agent-conventions.rfc.md +++ b/packages/@expo/agent-cli/llp/0010-agent-conventions.rfc.md @@ -80,7 +80,7 @@ A command that reports on the app does not gate on what it reported. `runtime:er `runtime:stop` and `dev:stop`: already in the asked-for state is success, exit 0. `wasRunning` and `reason: "not-running"` keep the fact itself readable. A device tool that refused is 1. A foreign dev server is 20. -The entry-bundle check (`src/runtime/bundleCheck.ts`) is what `smoke`, `runtime:reload`, and `dev --detach --wait-ready` call. A broken bundle is 20. A cold first build that does not finish inside `--timeout` is 22. `unknown` is not `broken`. `bundle.checked` is exactly `bundle.ok != null`. The `dev:wait` command itself is deferred ([[0017-deferred-commands]]). +The entry-bundle check (`src/runtime/bundleCheck.ts`) is what `smoke`, `runtime:reload`, `runtime:tree`, `runtime:tap` and `runtime:type` call. A broken bundle is 20. A cold first build that does not finish inside `--timeout` is 22. `unknown` is not `broken`. `bundle.checked` is exactly `bundle.ok != null`. The check sends its HEAD to the dev server it was given, the one of [[0005-runtime-loop-tools]] §Where a device reaches the dev server, with the path and query the manifest or the web page names and never their host. `bundle.url` is that URL. The reason: a `--tunnel` manifest names the tunnel host, and a HEAD of the bundle through the Expo tunnel closes every websocket on that tunnel, the app's debugger socket too [observed — 2026-10-27, `expo start --tunnel`, `@expo/ws-tunnel` 2.0.1]. A GET that is cancelled after its headers is the same abort, so it is not an alternative. Tests: `cloud-simulator-test` "restarts the app and then deep-links the route, and never closes it"; `bundleCheck-test` "should send the HEAD to the dev server given a launch asset on the tunnel host" and "should send the HEAD to the dev server when the page names another host"; `live-cloud-test` "runtime:reload --eas reloads the app and proves it, climbing to the rung that works" (`bundle.url` is on the local port). The `dev:wait` command itself is deferred ([[0017-deferred-commands]]). ## Needs-human protocol diff --git a/packages/@expo/agent-cli/llp/0022-live-tier.plan.md b/packages/@expo/agent-cli/llp/0022-live-tier.plan.md index 512d93b41..c01cf318d 100644 --- a/packages/@expo/agent-cli/llp/0022-live-tier.plan.md +++ b/packages/@expo/agent-cli/llp/0022-live-tier.plan.md @@ -273,7 +273,9 @@ The dev server's own tunnel is the public origin — v2. `expo start --tunnel` v the same flag uses `@expo/ws-tunnel` on the Expo account and works [verified live — 2026-09-05]. So the suite starts `dev --tunnel`, reads the advertised origin back through the CLI, and curls `/status` before it starts anything that bills — the tunnel is -tested surface now, not harness plumbing. `AGENT_CLI_LIVE_PUBLIC_ORIGIN` keeps the proxy +tested surface now, not harness plumbing. The entry-bundle check is not part of that surface: +it stays on the local dev server ([[0010-agent-conventions]] §Other gates), and the suite +asserts that. `AGENT_CLI_LIVE_PUBLIC_ORIGIN` keeps the proxy path (`EXPO_PACKAGER_PROXY_URL`) as the hatch, because the v2 flag is `UNSTABLE`-prefixed. A cloud reload takes whichever rung works, and which one that is turns out to be the diff --git a/packages/@expo/agent-cli/src/runtime/__tests__/bundleCheck-test.ts b/packages/@expo/agent-cli/src/runtime/__tests__/bundleCheck-test.ts index 849d72d43..315115772 100644 --- a/packages/@expo/agent-cli/src/runtime/__tests__/bundleCheck-test.ts +++ b/packages/@expo/agent-cli/src/runtime/__tests__/bundleCheck-test.ts @@ -217,6 +217,22 @@ describe(checkEntryBundleAsync, () => { expect(calls[1]).toMatchObject({ method: 'HEAD', url: WEB_BUNDLE_URL }); }); + // A page that names a host is sent to the dev server too, the way a tunnel manifest is. + it(`should send the HEAD to the dev server when the page names another host`, async () => { + const elsewhere = WEB_PAGE.replace('src="/', 'src="https://c-abc.on.expo.app/'); + mockFetch({ + 'http://127.0.0.1:8123/': { text: elsewhere }, + [`HEAD ${WEB_BUNDLE_URL}`]: { status: 200 }, + }); + + const result = await checkEntryBundleAsync(devServerUrl, { + platform: 'web', + timeoutMs: 5000, + }); + + expect(result).toMatchObject({ outcome: 'ok', url: WEB_BUNDLE_URL }); + }); + it(`should report the same TransformError a native check reports`, async () => { mockFetch({ 'http://127.0.0.1:8123/': { text: WEB_PAGE }, @@ -530,20 +546,29 @@ describe(checkEntryBundleAsync, () => { expect(result.reason).toContain('50ms'); }); - // The dev server answers with a path-relative URL when the request carried a `Forwarded` header. - it(`should resolve a relative launch asset against the dev server`, async () => { - const calls = mockFetch({ - 'http://127.0.0.1:8123/': { - json: { launchAsset: { url: 'index.bundle?platform=ios&dev=true' } }, - }, - 'HEAD http://127.0.0.1:8123/index.bundle?platform=ios&dev=true': { status: 200 }, + // The dev server answers with a path-relative URL when the request carried a `Forwarded` header, + // and a --tunnel dev server names its tunnel host. The HEAD goes to the dev server either way. + it.each([ + [ + 'a relative launch asset', + 'index.bundle?platform=ios&dev=true', + 'http://127.0.0.1:8123/index.bundle?platform=ios&dev=true', + ], + [ + 'a launch asset on the tunnel host', + 'https://c-abc.on.expo.app/index.bundle?platform=ios&dev=true', + 'http://127.0.0.1:8123/index.bundle?platform=ios&dev=true', + ], + ])(`should send the HEAD to the dev server given %s`, async (_name, launchAsset, expectedUrl) => { + // Any other URL throws in mockFetch, so `ok` is the proof that the HEAD went to the expected one. + mockFetch({ + 'http://127.0.0.1:8123/': { json: { launchAsset: { url: launchAsset } } }, + [`HEAD ${expectedUrl}`]: { status: 200 }, }); const result = await checkEntryBundleAsync(devServerUrl, { platform: 'ios', timeoutMs: 5000 }); - expect(result.outcome).toBe('ok'); - expect(result.url).toBe('http://127.0.0.1:8123/index.bundle?platform=ios&dev=true'); - expect(calls[1]!.url).toBe('http://127.0.0.1:8123/index.bundle?platform=ios&dev=true'); + expect(result).toMatchObject({ outcome: 'ok', url: expectedUrl }); }); it(`should treat a not-modified answer as a bundle that compiles`, async () => { diff --git a/packages/@expo/agent-cli/src/runtime/bundleCheck.ts b/packages/@expo/agent-cli/src/runtime/bundleCheck.ts index d558a7608..b2fbeb623 100644 --- a/packages/@expo/agent-cli/src/runtime/bundleCheck.ts +++ b/packages/@expo/agent-cli/src/runtime/bundleCheck.ts @@ -24,11 +24,14 @@ // the *source* of the URL differs by platform and everything after it does not. Asking for JSON // and reading the HTML answer as a parse failure is what left `--platform web` reporting // `checked: true, ok: null` with a `SyntaxError` for a reason [observed — friction run 2]. -// 2. A **HEAD** of that exact URL. HEAD builds the bundle and reports the real status without -// sending the 8 MB body [observed — Metro treats it as a normal request and only annotates it -// as a prefetch]; the body is only fetched when the status says something went wrong. The URL -// is used byte for byte, because every query parameter is part of Metro's graph id and changing -// one would compile a second graph rather than read the one the app uses. +// 2. A **HEAD** of the path and query of that URL, on the dev server it was given. HEAD builds +// the bundle and reports the real status without sending the 8 MB body [observed — Metro +// treats it as a normal request and only annotates it as a prefetch]; the body is only fetched +// when the status says something went wrong. The path and query are used byte for byte, +// because every query parameter is part of Metro's graph id and changing one would compile a +// second graph rather than read the one the app uses. The host the manifest or the page names +// is never used: behind `--tunnel` it is the tunnel host, and a HEAD of the bundle through the +// Expo tunnel closes every websocket on that tunnel [observed — 2026-10-27]. import { stripVTControlCharacters } from 'util'; @@ -213,23 +216,24 @@ export async function checkEntryBundleAsync( if (entry.url == null) { return finish({ outcome: 'unknown', url: null, error: null, reason: entry.reason }); } + const url = toDevServerUrl(entry.url, origin); // HEAD, so a bundle that compiles costs a status line instead of megabytes of JavaScript. - const head = await fetch(entry.url, { + const head = await fetch(url, { method: 'HEAD', signal: controller.signal, headers: { connection: 'close' }, }); if (head.ok || head.status === 304) { - return finish({ outcome: 'ok', url: entry.url, error: null }); + return finish({ outcome: 'ok', url, error: null }); } // Only now is the body worth having, and it is the small one: Metro answers a failed build // with a JSON object, not with a bundle. - const body = await fetch(entry.url, { signal: controller.signal }); + const body = await fetch(url, { signal: controller.signal }); return finish({ outcome: 'broken', - url: entry.url, + url, error: await readBundleErrorAsync(body), }); } catch (error: unknown) { @@ -252,6 +256,21 @@ export async function checkEntryBundleAsync( } } +/** + * The same path and query, on the dev server the check was given. + * + * The host a manifest or a page names is never used, a different port on the same host included: + * Metro keys the bundle on the path and query, never on the host. Why the tunnel host in particular + * must not be used is in the header comment, item 2. + */ +function toDevServerUrl(bundleUrl: string, origin: string): string { + const named = new URL(bundleUrl); + const url = new URL(`${origin}/`); + url.pathname = named.pathname; + url.search = named.search; + return url.toString(); +} + /** Where the entry bundle URL of one platform comes from, or why it could not be read. */ interface EntryBundleUrl { url: string | null; @@ -271,7 +290,7 @@ interface EntryBundleUrl { * The entry bundle URL this dev server would hand the app, for one platform. * * Two documents answer this and the platform decides which: native reads the manifest, web reads - * the page. Both end in the same place — one URL, used byte for byte. + * the page. Both end in the same place — one URL. */ function readEntryBundleUrlAsync( origin: string, @@ -592,7 +611,11 @@ export interface BundleCheckJson { ok: boolean | null; /** Platform the bundle was built for, or null when nothing was built. */ platform: BundleCheckPlatform | null; - /** Entry bundle URL that was fetched, resolved from the dev server's own manifest. */ + /** + * Entry bundle URL that was fetched: the path and query the dev server's own manifest (or web + * page) names, on the dev server the check was given. Behind a tunnel it is the local URL, not + * the public one the manifest names. + */ url: string | null; /** What the bundler reported. Present exactly when `ok` is false. */ error: BundleCheckError | null;