Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
a83ac2b
test: a holder's emulator connection reaches the emulator's adb port …
V3RON Nov 5, 2026
7f4b2ed
feat(tunnel): a holder's emulator tunnel reaches the emulator's adb p…
V3RON Nov 5, 2026
1b8897f
test: a holder's HELLO with an emulator entry is checked and answered…
V3RON Nov 5, 2026
da771a4
feat(tunnel): a HELLO with an emulator entry is checked and answered …
V3RON Nov 5, 2026
8669dd8
test: the catalog says which leases take an emulator port (#1289)
V3RON Nov 5, 2026
7253f1b
feat(core): the catalog delegates an emulator port to a driver that h…
V3RON Nov 5, 2026
f62a4a7
test: the real loopback listener asked for any free port reports the …
V3RON Nov 5, 2026
06f3ce9
feat(ports): listen(0) reports the port the OS bound (#1289)
V3RON Nov 5, 2026
470a746
test: the Android driver connects an emulator port to the right worke…
V3RON Nov 5, 2026
079d655
feat(android): the driver connects an emulator port to the worker por…
V3RON Nov 5, 2026
adc1990
test: simlock tunnel emulator adb prints the tunnel line and connects…
V3RON Nov 5, 2026
4bb855b
feat(cli): simlock tunnel emulator adb binds a free caller port, prin…
V3RON Nov 5, 2026
4ec902e
test: simlock tunnel emulator adb reports what the caller's adb answe…
V3RON Nov 5, 2026
3de1a6b
feat(cli): the caller's adb answer is reported, found in the SDK fold…
V3RON Nov 5, 2026
6b79eee
test: simlock tunnel emulator adb takes a caller port, runs beside ot…
V3RON Nov 5, 2026
a3d2651
feat(cli): an emulator tunnel takes a caller port, runs beside other …
V3RON Nov 5, 2026
8a3e038
test: simlock tunnel emulator adb disconnects the caller's adb howeve…
V3RON Nov 5, 2026
863ec60
feat(cli): simlock tunnel emulator adb disconnects the caller's adb w…
V3RON Nov 5, 2026
aa2138e
test: simlock tunnel emulator that cannot run exits 2 with a usage er…
V3RON Nov 5, 2026
90d8932
feat(cli): simlock tunnel emulator usage errors and help (#1289)
V3RON Nov 5, 2026
e1e6f9c
docs: simlock tunnel emulator adb in the CLI, remote agents and error…
V3RON Nov 5, 2026
1551749
test(e2e): the caller's own adb sees and drives the emulator through …
V3RON Nov 5, 2026
6966400
test(e2e): the emulator tunnel test finds the SDK's adb under its own…
V3RON Nov 5, 2026
07c75d0
refactor(cli): the emulator tunnel's parse, route line and adb wiring…
V3RON Nov 5, 2026
44b3fec
test(cli): the emulator tunnel tests fail on a named assertion when t…
V3RON Nov 5, 2026
d57a31a
test(cli): the emulator tunnel tests also prove a driver without a st…
V3RON Nov 5, 2026
bc4457b
test(e2e): the emulator adb tunnel test leases a google_apis image, a…
V3RON Nov 5, 2026
9163a2a
docs(adr): 0064 is Accepted (#1289)
V3RON Nov 5, 2026
48a1ed2
refactor(contract): the warmPool config shape matches the ADR branch'…
V3RON Nov 5, 2026
bd0bb56
Merge origin/task/1288 into task/1289
V3RON Nov 5, 2026
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
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 0064. A tunnel reaches an iOS simulator's ports and an emulator's adb port

- **Status:** Accepted — not yet implemented
- **Status:** Accepted
- **Date:** 2026-11-04
- **Issue:** [#1282](https://github.com/callstackincubator/simlock/issues/1282)
- **Narrows:** [ADR 0054](0054-a-lease-holder-tunnels-device-ports-to-its-own-machine.md)
Expand Down
2 changes: 1 addition & 1 deletion docs/internal/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,5 +99,5 @@ the status is stale.
| [0061](0061-every-virtual-device-is-a-copy-of-a-source-and-a-baseline-is-a-named-source.md) | Every virtual device is a copy of a source, and a baseline is a named source | Accepted; narrows 0001 §1, §3, §4, §5, §6, 0007 §6, 0009 §2, §4, §6, 0015 §6–7, 0038 §5 and 0060 §4; §5 narrowed by 0062 |
| [0062](0062-the-warm-pool-learns-a-baseline-after-repeated-leases-and-follows-its-newest-version.md) | The warm pool learns a baseline after repeated leases, and follows its newest version | Accepted; narrows 0038 §1, §4, §5, §8, 0050 and 0061 §5 |
| [0063](0063-a-big-module-is-one-composition-root-over-parts-that-own-their-state.md) | A big module is one composition root over parts that own their state | Accepted |
| [0064](0064-a-tunnel-reaches-an-ios-simulators-ports-and-an-emulators-adb-port.md) | A tunnel reaches an iOS simulator's ports and an emulator's adb port | Accepted — not yet implemented; narrows 0054 §2, §5, §6, 0001 §4 and 0005 Non-goals |
| [0064](0064-a-tunnel-reaches-an-ios-simulators-ports-and-an-emulators-adb-port.md) | A tunnel reaches an iOS simulator's ports and an emulator's adb port | Accepted; narrows 0054 §2, §5, §6, 0001 §4 and 0005 Non-goals |
| [0065](0065-a-driver-owns-its-config-keys-and-a-module-takes-the-sections-it-reads.md) | A driver owns its config keys, and a module takes the config sections it reads | Accepted |
2 changes: 2 additions & 0 deletions e2e/helpers/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export interface CliBackgroundHandle {
progressEvents(): readonly unknown[];
/** Everything written to stdout so far, for a process that streams until it is stopped. */
stdoutSoFar(): string;
stderrSoFar(): string;
waitForExit(timeout?: number): Promise<CliResult>;
kill(signal?: NodeJS.Signals): void;
readonly pid: number | undefined;
Expand Down Expand Up @@ -142,6 +143,7 @@ export function cliBackground(
return {
pid: child.pid,
stdoutSoFar: () => stdout,
stderrSoFar: () => stderr,
async firstStdoutLine(timeout = 30_000) {
if (firstLineSeen !== undefined) return firstLineSeen;
if (exitedWithoutLine !== undefined) throw exitedWithoutLine;
Expand Down
91 changes: 82 additions & 9 deletions e2e/slow-android-tunnel.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { execFile } from "node:child_process";
import { existsSync } from "node:fs";
import { existsSync, mkdtempSync } from "node:fs";
import { createServer } from "node:http";
import { connect } from "node:net";
import { homedir } from "node:os";
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";

Expand Down Expand Up @@ -161,7 +161,7 @@ describe.skipIf(!hasAndroidSdk)(
{ tags: ["slow", "android"] },
() => {
it(
"serves the caller's page to the emulator through reverse, and a device server's bytes to the caller's loopback port through forward, with the caller's bytes arriving",
"serves the caller's page to the emulator through reverse, and a device server's bytes to the caller's loopback port through forward, with the caller's bytes arriving, and lets the caller's own separate adb server list the emulator and run a shell command on it through emulator adb while Simlock's own adb still lists it",
{ timeout: 480_000 },
async () => {
const gatewayPort = await freeLoopbackPort();
Expand Down Expand Up @@ -206,6 +206,9 @@ describe.skipIf(!hasAndroidSdk)(
"android",
"--device",
"Pixel 9",
// why: the caller's adb has its own key, which a Play Store image (`ro.adb.secure` 1) answers `unauthorized`; a `google_apis` image takes any key, and the newest image Pixel 9 fits may be a Play Store one.
"--image-tag",
"google_apis",
"--agent-id",
"tunnel",
"--detach",
Expand Down Expand Up @@ -313,6 +316,70 @@ describe.skipIf(!hasAndroidSdk)(
);
server.kill("SIGINT");

const callerServerPort = await freeLoopbackPort();
const callerHome = mkdtempSync(join(tmpdir(), "simlock-caller-adb-"));
const callerAdb = (args: readonly string[]) =>
workerAdb(callerServerPort, args, { HOME: callerHome });
const driven = gateway.cliBackground(
[
"--token",
secret,
"tunnel",
"emulator",
"adb",
"--url",
`http://127.0.0.1:${String(gatewayPort)}`,
"--lease",
leaseId,
],
{
env: {
ANDROID_ADB_SERVER_PORT: String(callerServerPort),
ANDROID_HOME,
HOME: callerHome,
},
},
);
try {
const line = await driven.firstStdoutLine(60_000);
const callerPort = /localhost:(\d+) -> emulator:adb/.exec(line)?.[1] ?? "";
expect(line).toBe(
`emulator localhost:${callerPort} -> emulator:adb (lease ${leaseId})`,
);
const device = `127.0.0.1:${callerPort}`;
await waitFor(
async () => driven.stdoutSoFar().includes(`adb: connected to ${device}`),
{
interval: 500,
label: () =>
`the command connecting the caller's adb, its stderr: ${driven.stderrSoFar()}`,
timeout: 30_000,
},
);
let listed = "";
await waitFor(
async () => {
listed = (await callerAdb(["devices"])).stdout;
return new RegExp(`^${device}\\s+device$`, "m").test(listed);
},
{
interval: 1_000,
label: () =>
`the caller's adb listing the emulator as a device, last list: ${listed}`,
timeout: 30_000,
},
);
expect((await callerAdb(["-s", device, "shell", "echo", "ok"])).stdout.trim()).toBe(
"ok",
);
} finally {
driven.kill("SIGINT");
expect((await driven.waitForExit(10_000)).code).toBe(0);
await callerAdb(["kill-server"]);
}
const own = await leaseAdb(gateway, { agentId: "tunnel", leaseId }, ["get-state"]);
expect(own.stdout.trim()).toBe("device");

tunnel.kill("SIGINT");
expect((await tunnel.waitForExit(10_000)).code).toBe(0);
} finally {
Expand All @@ -334,14 +401,20 @@ const ADB = join(ANDROID_HOME, "platform-tools", "adb");
function workerAdb(
serverPort: number,
args: readonly string[],
env: NodeJS.ProcessEnv = {},
): Promise<{ code: number; stdout: string }> {
return new Promise((resolve) => {
execFile(ADB, ["-P", String(serverPort), ...args], (error, stdout) => {
resolve({
code: error === null ? 0 : typeof error.code === "number" ? error.code : 1,
stdout,
});
});
execFile(
ADB,
["-P", String(serverPort), ...args],
{ env: { ...process.env, ...env } },
(error, stdout) => {
resolve({
code: error === null ? 0 : typeof error.code === "number" ? error.code : 1,
stdout,
});
},
);
});
}

Expand Down
4 changes: 2 additions & 2 deletions src/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,12 +98,12 @@ Surface: [cli.mdx](../../website/src/content/docs/docs/reference/cli.mdx) — ev
orientation, an unknown one is a usage error (exit 2); the lease is `--lease` (anywhere on the line)
or the requester's only lease (`resolveRemoteLeaseId`); `device.rotate` answers, and the line printed
is `Rotated lease <id> to <orientation>` from the answer. Worker or gateway alike.
5b. **`simlock tunnel reverse|forward <d>[:<c>] … [--lease <id>] [--url <u>]`** — talks to a worker or gateway over
5b. **`simlock tunnel reverse|forward <d>[:<c>] | emulator adb[:<c>] … [--lease <id>] [--url <u>]`** — talks to a worker or gateway over
HTTP and a WebSocket, never the local daemon: `--url`/`SIMLOCK_URL` (`https://`, or `http://` on loopback only; the origin, no path, query or fragment)
and `--token`/`SIMLOCK_TOKEN`. Without `--lease`, `GET /v1/identity` and `/v1/leases` pick an agent token's only
lease (`selectOnlyLease`; an operator token must name one). One line per tunnel on `READY`; each `OPEN` connects
to the caller's loopback port (no listener: `RESET` and a stderr warning per tunnel every 10 s), and `CREDIT`
flows both ways. A `forward` tunnel binds its caller port (loopback, 1024 and up) through `tcpListeners` before anything else (busy: exit 1 `TUNNEL_CALLER_PORT_IN_USE`), holds accepted connections until `READY` (closed on `REFUSED`), opens each with an even-id `OPEN`, and closes every listener when the command ends. A `RESET {reason}` of a forward connection prints `warning: forward <device-port>: <reason>` on stderr, at most once per 10 s per tunnel, and the tunnel stays. Failures print one JSON line itself: a refused upgrade or `REFUSED` frame with the worker's code,
flows both ways. A `forward` tunnel binds its caller port (loopback, 1024 and up) through `tcpListeners` before anything else (busy: exit 1 `TUNNEL_CALLER_PORT_IN_USE`), holds accepted connections until `READY` (closed on `REFUSED`), opens each with an even-id `OPEN`, and closes every listener when the command ends. A `RESET {reason}` of a forward connection prints `warning: forward <device-port>: <reason>` on stderr, at most once per 10 s per tunnel, and the tunnel stays. An `emulator adb[:<c>]` tunnel binds like a forward (a free port with no `:<c>`, the bound one printed), is named once, and on `READY` runs the caller's own adb through `environment.processRunner` (`callerAdb`): `adb connect 127.0.0.1:<c>` (10 s), the commands tried being `environment.adbCandidates` (`adb`, then the `platform-tools` of `ANDROID_HOME`, `ANDROID_SDK_ROOT` and the default SDK folder); its output without `*` lines is `adb: connected to …` or a `warning: emulator adb: … ; run: adb connect …` and the tunnel stays. When the command leaves (Ctrl-C, a refusal, lease end, a lost connection) it kills a connect still running and runs `adb disconnect` (2 s), whatever its answer; the exit code is the command's own. Failures print one JSON line itself: a refused upgrade or `REFUSED` frame with the worker's code,
close 4001 exit 14 `TUNNEL_LEASE_ENDED`, a lost socket, 45 s of silence or 90 s without `READY` `TUNNEL_DISCONNECTED`.
Ctrl-C and SIGTERM close the socket, wait at most 2 s, exit 0. `ws` loads on the first connect, for this command only.
6. **`simlock events --follow`** — subscribe, then replay, then print buffered pushes not
Expand Down
Loading
Loading