Skip to content
2 changes: 1 addition & 1 deletion docs/internal/adr/0001-simlock-owned-device-roots.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

- **Status:** Accepted. Root membership as the only proof of ownership, and the
Android driver's one adb server, are narrowed by [ADR
0022](0022-a-physical-device-is-a-second-device-type-the-core-knows.md) for physical devices. Per-home isolation as a user feature is superseded by [ADR 0034](0034-one-worker-per-user-and-the-folder-says-what-a-daemon-is.md): one worker per user. §1's layout is narrowed by [ADR 0036](0036-daemon-state-is-a-sqlite-database-whose-tables-each-owner-keeps.md) (`state.json` becomes `state.db`). §1 (every driver owns a root), §2, §3, §5, §6 and §7 are narrowed by [ADR 0048](0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md) for iOS simulators under `drivers.ios.deviceSet: "default"`. The Consequences line on two instances needing distinct `adbServerPort` values is narrowed by [ADR 0059](0059-a-worker-with-real-drivers-holds-a-per-user-lock.md).
0022](0022-a-physical-device-is-a-second-device-type-the-core-knows.md) for physical devices. Per-home isolation as a user feature is superseded by [ADR 0034](0034-one-worker-per-user-and-the-folder-says-what-a-daemon-is.md): one worker per user. §1's layout is narrowed by [ADR 0036](0036-daemon-state-is-a-sqlite-database-whose-tables-each-owner-keeps.md) (`state.json` becomes `state.db`). §1 (every driver owns a root), §2, §3, §5, §6 and §7 are narrowed by [ADR 0048](0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md) for iOS simulators under `drivers.ios.deviceSet: "default"`. The Consequences line on two instances needing distinct `adbServerPort` values is narrowed by [ADR 0059](0059-a-worker-with-real-drivers-holds-a-per-user-lock.md). §4's "cannot see" line is narrowed by [ADR 0064](0064-a-tunnel-reaches-an-ios-simulators-ports-and-an-emulators-adb-port.md) for a holder's own adb through a tunnel.
- **Date:** 2026-09-02
- **Issue:** [#73](https://github.com/callstackincubator/simlock/issues/73),
part of [#70](https://github.com/callstackincubator/simlock/issues/70)
Expand Down
2 changes: 1 addition & 1 deletion docs/internal/adr/0005-gateway-and-worker-modes.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 0005. Gateway and worker modes

- **Status:** Accepted — partly implemented: §16, §19a', §19c and §27a; requirement 1, the `mode` key and the two-homes setup superseded by 0034; requirement 3's joined-worker HTTP permission superseded by [0039](0039-joined-workers-refuse-their-own-http-listener.md); §19c's piped stdin narrowed by [0058](0058-through-a-gateway-simlock-adb-and-simctl-send-stdin-only-with-stdin.md); Non-goals (port forwarding) narrowed by [0054](0054-a-lease-holder-tunnels-device-ports-to-its-own-machine.md)
- **Status:** Accepted — partly implemented: §16, §19a', §19c and §27a; requirement 1, the `mode` key and the two-homes setup superseded by 0034; requirement 3's joined-worker HTTP permission superseded by [0039](0039-joined-workers-refuse-their-own-http-listener.md); §19c's piped stdin narrowed by [0058](0058-through-a-gateway-simlock-adb-and-simctl-send-stdin-only-with-stdin.md); Non-goals (port forwarding) narrowed by [0054](0054-a-lease-holder-tunnels-device-ports-to-its-own-machine.md) and [0064](0064-a-tunnel-reaches-an-ios-simulators-ports-and-an-emulators-adb-port.md)
- **Date:** 2026-09-05
- **Issue:** [#115](https://github.com/callstackincubator/simlock/issues/115)
(sub-issues #116–#120, one per sequenced PR)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 0054. A lease holder tunnels device ports to its own machine

- **Status:** Accepted
- **Status:** Accepted; §2, §5 and §6 and the iOS Consequence narrowed by [ADR 0064](0064-a-tunnel-reaches-an-ios-simulators-ports-and-an-emulators-adb-port.md)
- **Date:** 2026-10-30
- **Issue:** [#1110](https://github.com/callstackincubator/simlock/issues/1110)
- **Narrows:** [ADR 0005](0005-gateway-and-worker-modes.md) Non-goals ("A
Expand Down Expand Up @@ -175,7 +175,7 @@ sequenceDiagram
- The first daemon-contract helper for a module's own socket protocol.
- A third file loads `ws`, and only when `simlock tunnel` runs.
- Each worker reserves loopback ports 47000 to 47999 for tunnels.
- iOS simulators are not covered: their `reverse` would be a port on the
- iOS simulators are not covered (ADR 0064 covers them): their `reverse` would be a port on the
worker shared by every lease, the host-port problem #162 owns.

## Alternatives considered
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
# 0064. A tunnel reaches an iOS simulator's ports and an emulator's adb port

- **Status:** Accepted — not yet implemented
- **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)
§2 (a tunnel is "reverse or forward" on "a device port"), §5 ("Other
devices are refused"), §6 (listeners in 47000 to 47999) for iOS
simulators, and its Consequence "iOS simulators are not covered".
[ADR 0001](0001-simlock-owned-device-roots.md) §4 ("the user's adb
server and Android Studio cannot see, drive, or kill a Simlock
emulator") for a holder's own adb on its own machine.
[ADR 0005](0005-gateway-and-worker-modes.md) Non-goals (port forwarding)
further, as ADR 0054 did.

## Context

`simlock tunnel` carries TCP between the caller's machine and a leased
Android device (ADR 0054). Two everyday cases are still out of reach.

An iOS simulator's lease is refused. A simulator has no network stack of
its own: an app in it that listens on port 9100 listens on the worker
Mac's loopback, and an app that opens `localhost:8081` reaches the worker's
port 8081. Every simulator on a worker shares that one port space. So
Metro, a mock API or Flutter's VM Service cannot cross machines on iOS.

A local adb, and so Android Studio, cannot see a remote emulator. adb
talks to an emulator through a TCP port the emulator program opens on the
worker (console port + 1), not through any port inside Android, so a
device-port tunnel cannot reach it.

A spike on 2026-11-04 (Xcode 27.0, iOS 26.4.1 runtime, emulator 36.5.11,
adb 37.0.0) checked what this rests on:

- A second adb server attached to an emulator's adb port through a plain
TCP relay: `adb connect` gave `device`; shell, install, `logcat` and
`adb reverse` worked on both servers at once; Simlock's server kept
`emulator-<port> device` throughout and after the second one left.
- `adb emu …` from the second server fails: the console is a separate port.
- A `google_apis` image has `ro.adb.secure` 0 and takes any key; a
`google_apis_playstore` image has 1 and answers `unauthorized` to a key
it was not booted with.
- A process a simulator runs carries `SIMULATOR_UDID=<udid>` in its
environment, readable with `ps eww` without root in about 15 ms; its
parent chain breaks once it double-forks.
- An app's `getaddrinfo("localhost")` gives `::1`, then `127.0.0.1`; a
refused `::1` costs no time. Two simulators cannot bind the same port.
- With a process holding `[::]:<p>`, Node still bound `127.0.0.1:<p>` and
`::1:<p>` (spec check, same day).

## Decision

1. **Two target kinds.** A tunnel names a **device port**, as today: a
port opened on the device, for `reverse` or `forward`. Or it names an
**emulator port**: a port the emulator program opens on the worker for
one emulator, named, never numbered by the caller. The only name is
`adb`. The console needs the worker's auth token and gRPC runs with
`-grpc-use-token`, so they are left out; a later name is one more value.
`HELLO` names one as `{kind: "emulator", emulatorPort: "adb"}`.

2. **iOS simulator, forward.** Each caller connection makes the worker
list the processes listening on the device port and the address each
listens on, and connect only to an address that a process carrying
`SIMULATOR_UDID` equal to the lease's simulator holds. It dials
`127.0.0.1:<p>` when a lease process holds `127.0.0.1:<p>`, or holds
`*:<p>` while no other process holds `127.0.0.1:<p>`; otherwise
`[::1]:<p>` by the same rule. It never dials an address another process
holds more specifically, because a loopback connect reaches the most
specific bind. Any other listener, or none, resets that connection
with a reason; the tunnel stays. The check runs per connection, because
an app starts, stops and restarts while a tunnel is open. So a holder
never reaches the daemon, its adb server, or another lease's app.

3. **iOS simulator, reverse.** The worker listens on exactly the device
port, on both `127.0.0.1` and `::1`, so `localhost` reaches it whichever
address an app tries first. If anything already answers on the port
at either loopback address (a listener on all interfaces included), or
either bind fails, the tunnel is
refused `TUNNEL_PORT_IN_USE`: the first listener on a worker wins a
port. The bind alone is not the check, because macOS lets both
loopback binds succeed while another process holds the port on all
interfaces, as the worker's own HTTP server can. Nothing
is set on the device, so these listeners are outside 47000 to 47999,
sweep and reclaim have nothing to remove, and the listeners close with
the socket. Like ADR 0054 §4, any simulator or local process on the
worker can reach the listener while it is open.

4. **An iPhone stays refused**, for device ports and emulator ports.

5. **Emulator port `adb`.** Each caller connection makes the worker
connect to `127.0.0.1:<console port + 1>` of the lease's own emulator.
A phone or an iOS lease is refused `TUNNEL_UNSUPPORTED`. The holder's
adb then has the whole emulator, with none of `simlock adb`'s
refusals: those guard the worker's shared adb server and the console,
and this tunnel reaches neither. A reboot or a crash is a device lost
mid-lease, as today. ADR 0001 §4 now reads: the user's adb server and
Android Studio cannot see a Simlock emulator on the worker; a holder's
own adb, through its own tunnel, may. A Play Store image answers `unauthorized`; the docs say to
lease a `google_apis` image.

6. **CLI.** `simlock tunnel emulator adb[:<caller-port>]`. With no caller
port it listens on a free one. Once `READY`, it runs `adb connect
127.0.0.1:<caller port>` on the caller's machine, and `adb disconnect`
for that address when it exits. No adb found, or a failed connect, is a
warning that prints the command to run; the tunnel stays.

7. **No protocol bump.** The frames are a contract helper outside the
bump check, and no error code is added. A new CLI against an older
worker gets that worker's `BAD_REQUEST` for the unknown kind.

```mermaid
flowchart LR
subgraph Caller machine
T[simlock tunnel]
ADB[caller's adb / Studio]
end
subgraph Worker Mac
M[tunnel module]
L["127.0.0.1 / ::1 :port"]
E["emulator program :console+1"]
end
S[iOS simulator app]
T <-->|one WebSocket, via gateway| M
ADB --> T
M -->|forward, owner checked| L
S -->|listens on / connects to| L
M -->|emulator port adb| E
```

```mermaid
sequenceDiagram
participant C as simlock tunnel (caller)
participant W as Worker tunnel module
participant P as Listener on worker loopback
C->>W: HELLO forward 9100 (iOS lease)
W-->>C: READY
C->>W: OPEN forward 9100
W->>P: who listens on 9100? SIMULATOR_UDID?
alt the lease's simulator
W->>P: connect, bytes flow
else another process or none
W-->>C: RESET {reason}
end
```

## Consequences

- Metro, mock servers and Flutter's VM Service work on a remote iOS
simulator, and a local adb or Android Studio works on a remote emulator.
- Two leases on one worker cannot reverse the same port on iOS; the
second is refused and must pick another port.
- A forward connection on iOS costs a listener lookup (about 0.1 s).
- The caller's own adb reverse rules show in the worker's
`adb reverse --list` for that emulator; they go when the emulator is
destroyed at lease end.
- A Play Store emulator image cannot take the caller's adb.

## Alternatives considered

- **Any loopback port for an iOS forward, or all but Simlock's own.**
Rejected: the worker's adb server drives every emulator, and other
leases' apps listen on the same loopback.
- **Wait for host ports leased with the device (#162) before iOS
reverse.** Rejected: Metro's fixed 8081 is the common case, and a
refused clash is clear.
- **Filter the caller's adb traffic.** Rejected: large, fragile, and the
refusals it would copy guard what this tunnel cannot reach.
- **Tunnel the console and gRPC ports too.** Rejected for now: both need
a per-worker secret the caller does not have.
- **A raw worker port number.** Rejected: the caller cannot know it, and
it would reach other leases' emulators.
7 changes: 4 additions & 3 deletions docs/internal/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,11 @@ the status is stale.

| ADR | Title | Status |
|---|---|---|
| [0001](0001-simlock-owned-device-roots.md) | Simlock-owned device roots | Accepted; ownership proof and the adb server narrowed by 0022 for physical devices; per-home isolation as a user feature superseded by 0034; §1's layout narrowed by 0036 (`state.db`); §1, §2, §3, §5, §6 and §7 narrowed by 0048 for iOS under `deviceSet: "default"`; Consequences' two-instance line narrowed by 0059; §1, §3, §4, §5 and §6 narrowed by 0061 |
| [0001](0001-simlock-owned-device-roots.md) | Simlock-owned device roots | Accepted; ownership proof and the adb server narrowed by 0022 for physical devices; per-home isolation as a user feature superseded by 0034; §1's layout narrowed by 0036 (`state.db`); §1, §2, §3, §5, §6 and §7 narrowed by 0048 for iOS under `deviceSet: "default"`; Consequences' two-instance line narrowed by 0059; §1, §3, §4, §5 and §6 narrowed by 0061; §4's "cannot see" line narrowed by 0064 |
| [0002](0002-opt-in-slim-ios-simulators.md) | Opt-in slim iOS simulators | Accepted; §2, §3, §5, §8, §9, the overrides' location and the no-enable-pass cost superseded by 0060; §10 narrowed by 0060 |
| [0003](0003-one-typed-daemon-contract-behind-every-frontend.md) | One typed daemon contract behind every frontend | Accepted |
| [0004](0004-ttl-first-leases-on-every-transport.md) | TTL-first leases on every transport | Accepted — partly implemented: §2 (the CLI exits when its socket drops instead of keeping its lease) |
| [0005](0005-gateway-and-worker-modes.md) | Gateway and worker modes | Accepted — partly implemented: §16, §19a', §19c and §27a; requirement 1, the `mode` key and the two-homes setup superseded by 0034; requirement 3's joined-worker HTTP permission superseded by 0039; §19c's piped stdin narrowed by 0058; Non-goals (port forwarding) narrowed by 0054 |
| [0005](0005-gateway-and-worker-modes.md) | Gateway and worker modes | Accepted — partly implemented: §16, §19a', §19c and §27a; requirement 1, the `mode` key and the two-homes setup superseded by 0034; requirement 3's joined-worker HTTP permission superseded by 0039; §19c's piped stdin narrowed by 0058; Non-goals (port forwarding) narrowed by 0054 and 0064 |
| [0006](0006-events-and-log-are-two-records.md) | Events and the daemon log are two records | Accepted; §2 narrowed by 0032 (the event record is a database) |
| [0007](0007-a-lease-request-chooses-the-device-mode.md) | A lease request chooses the device mode | Accepted |
| [0008](0008-the-catalog-pairs-models-with-runtimes-and-status-carries-host-facts.md) | The catalog pairs models with runtimes, and status carries host facts | Accepted; §3 narrowed by 0025 on Android |
Expand Down Expand Up @@ -92,11 +92,12 @@ the status is stale.
| [0051](0051-configured-warm-targets-come-first-and-learning-can-be-turned-off.md) | Configured warm targets come first, and learning can be turned off | Accepted |
| [0052](0052-an-ios-simulator-takes-serve-sims-rotation.md) | An iOS simulator takes serve-sim's rotation | Accepted; narrows 0049 §3 on iOS simulators |
| [0053](0053-the-daemon-owns-each-leases-device-input.md) | The daemon owns each lease's device input, and `device.rotate` uses it | Accepted; narrows 0049 §4 and 0052 §1 |
| [0054](0054-a-lease-holder-tunnels-device-ports-to-its-own-machine.md) | A lease holder tunnels device ports to its own machine | Accepted; narrows 0005 Non-goals (port forwarding) |
| [0054](0054-a-lease-holder-tunnels-device-ports-to-its-own-machine.md) | A lease holder tunnels device ports to its own machine | Accepted; narrows 0005 Non-goals (port forwarding); §2, §5 and §6 and the iOS Consequence narrowed by 0064 |
| [0058](0058-through-a-gateway-simlock-adb-and-simctl-send-stdin-only-with-stdin.md) | Through a gateway, `simlock adb` and `simlock simctl` send stdin only with `--stdin` | Accepted; narrows 0005 §19c |
| [0059](0059-a-worker-with-real-drivers-holds-a-per-user-lock.md) | A worker with real drivers holds a per-user lock | Accepted; narrows 0034 §1 |
| [0060](0060-ios-slim-writes-launchd-overrides-while-the-simulator-is-shut-down.md) | iOS slim writes launchd overrides while the simulator is shut down | Accepted; supersedes 0002 §2, §3, §5, §8, §9 |
| [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 |
| [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 |
Loading