iOS Physical Device — Build & Deployment Guide

Copy Markdown View Source

Physical iOS devices are fundamentally different from the simulator in ways that are not obvious and will waste days if you don't know about them. This document covers the happy path and every significant pitfall encountered when getting the BEAM running on a real iPhone for the first time.


Why the simulator is not a useful proxy

The iOS simulator runs on macOS. The app process is a macOS process. This means:

  • /tmp is the Mac's /tmp — shared between the simulator and the host. OTP can live at a fixed path like /tmp/otp-ios-sim and be found at runtime.
  • The network stack is the Mac's network stack. EPMD running on the Mac is reachable from the simulator on localhost:4369.
  • dlopen works normally. .so NIFs load without restriction.
  • Memory limits are the Mac's limits. The BEAM's default 1 GB virtual super carrier reservation succeeds.
  • Executables in the bundle can be exec'd without restriction.

None of these are true on a physical device. Each point is a separate crash that gives no obvious error.


Happy path

Build paths: simulator vs device

Dala uses two separate build scripts for iOS:

ScriptPurposeCalled by
ios/build.shiOS simulator buildsDalaDev.NativeBuild.build_ios/1
ios/build_device.shiOS physical device buildsDalaDev.NativeBuild.build_ios_physical/2

When deploying with mix dala.deploy, pass --device <udid> to target a physical device:

# Simulator (default)
mix dala.deploy

# Physical device
mix dala.devices                    # list devices and get the UDID
mix dala.deploy --device <udid>    # deploy to specific device

dala_dev resolves the UDID via DalaDev.Discovery.IOS.list_devices/0 and selects the correct build path automatically. Do not shortcut this — the simulator and device build chains are different (static linking, OTP bundling, EPMD as in-process thread, etc.).

1. Build OTP from source for arm64-apple-ios

cd /tmp/otp_ios_device_build
git clone https://github.com/erlang/otp.git
cd otp
git checkout OTP-28.1

./otp_build autoconf
./configure \
  --host=aarch64-apple-ios \
  --build=arm64-apple-darwin \
  --with-ssl=no \
  --disable-jit \
  --disable-esock \
  --without-asn1 \
  --without-runtime_tools \
  --without-os_mon \
  CC="xcrun -sdk iphoneos clang -arch arm64 -miphoneos-version-min=17.0 \
      -isysroot $(xcrun -sdk iphoneos --show-sdk-path)"

make -j$(sysctl -n hw.ncpu)

Critical flags and why:

  • --disable-jit — iOS enforces W^X (write xor execute). The JIT allocates writable+executable memory, which is rejected by the kernel on real hardware. The simulator's macOS process has no such restriction.
  • --disable-esocknet/if_arp.h is missing from the iOS SDK; configure fails without this.
  • --without-asn1 --without-runtime_tools --without-os_mon — these OTP applications contain NIFs that reference ERTS symbols not exported from a static libbeam.a. The linker cannot resolve them.

2. Assemble the OTP cache

Collect static libs from the build tree into a cache directory:

cache/otp-ios-device-<hash>/
  erts-16.1/
    include/           copy from build: erts/include/ + erts/aarch64-apple-ios/
    lib/
      libbeam.a             from bin/aarch64-apple-ios/
      libepcre.a            from erts/emulator/pcre/obj/aarch64-apple-ios/opt/
      libryu.a              from erts/emulator/ryu/obj/aarch64-apple-ios/opt/
      libzstd.a
      asn1rt_nif.a          built separately (see pitfalls)
      internal/
        liberts_internal_r.a
        libethread.a
  lib/                 OTP applications (kernel, stdlib, elixir, logger, etc.)
  releases/            boot scripts

3. Bundle OTP inside the .app

On the simulator, /tmp is shared with the Mac so OTP can live at a fixed path. On the device, /tmp is the app's sandbox — empty on every install. OTP must be bundled inside the .app and the path resolved at runtime:

#ifdef dala_BUNDLE_OTP
NSString *bundle_otp = [[[NSBundle mainBundle] bundlePath]
                         stringByAppendingPathComponent:@"otp"];
const char *otp_root = [bundle_otp UTF8String];
#endif

Bundle the OTP tree at .app/otp/ using rsync during the build:

OTP_BUNDLE="$APP/otp"
rsync -a --delete "$OTP_ROOT/lib/"      "$OTP_BUNDLE/lib/"
rsync -a --delete "$OTP_ROOT/releases/" "$OTP_BUNDLE/releases/"
rsync -a --delete "$OTP_ROOT/dala_qa/"   "$OTP_BUNDLE/dala_qa/"
mkdir -p "$OTP_BUNDLE/$ERTS_VSN/bin"    # BINDIR must exist (even if empty)

Compile dala_beam.m with -Ddala_BUNDLE_OTP -DERTS_VSN=\"erts-16.1\" -DOTP_RELEASE=\"28\".

4. Cap the BEAM memory super carrier

The BEAM reserves a 1 GB virtual address range for its memory super carrier by default. macOS grants this without complaint. iOS on real hardware rejects it and the process is killed silently before any Elixir code runs. Add -MIscs 10 to the erl_start args to cap it at 10 MB:

#ifdef dala_BUNDLE_OTP
"-MIscs", "10",
#endif

5. Run EPMD as an in-process thread

Erlang distribution requires EPMD listening on port 4369 before erl_start. On the simulator this works because the Mac's EPMD is already running and reachable via the shared network stack. On the device, there is no EPMD.

The fix: compile the EPMD sources directly into the app binary and start them on a pthread before calling erl_start:

# In build_device.sh — compile EPMD with renamed main()
OTP_BUILD_SRC="/path/to/otp_build"
EPMD_SRC="$OTP_BUILD_SRC/erts/epmd/src"

xcrun -sdk iphoneos clang -arch arm64 -miphoneos-version-min=17.0 \
    -DHAVE_CONFIG_H -DEPMD_PORT_NO=4369 -Dmain=epmd_ios_main \
    -I "$OTP_BUILD_SRC/erts/aarch64-apple-ios" \
    -I "$EPMD_SRC" \
    -I "$OTP_BUILD_SRC/erts/include" \
    -I "$OTP_BUILD_SRC/erts/include/internal" \
    -c "$EPMD_SRC/epmd.c"     -o epmd_main.o
# repeat for epmd_srv.c and epmd_cli.c
// dala_beam.m
#ifdef dala_BUNDLE_OTP
extern int epmd_ios_main(int argc, char **argv);
static void* epmd_thread(void *arg) {
    char *args[] = {"epmd", NULL};
    epmd_ios_main(1, args);   // event loop; does not return
    return NULL;
}
#endif

// ... before erl_start() ...
#ifdef dala_BUNDLE_OTP
pthread_t epmd_t;
pthread_create(&epmd_t, NULL, epmd_thread, NULL);
pthread_detach(epmd_t);
usleep(300000);  // give EPMD 300ms to bind port 4369
#endif

Do not pass -daemon to epmd_ios_main. The daemon path calls fork(), which is not permitted in iOS sandboxed apps.

6. Register third-party NIFs in the static NIF table

iOS cannot dlopen .so NIFs. All NIFs must be statically linked into the binary and registered in driver_tab_ios.c (which overrides ERTS's erts_static_nif_tab by being linked before libbeam.a).

For exqlite's sqlite3_nif:

# Compile with STATIC_ERLANG_NIF_LIBNAME to get a predictable init symbol
$CC ... \
    -DSTATIC_ERLANG_NIF_LIBNAME=sqlite3_nif \
    -c deps/exqlite/c_src/sqlite3_nif.c -o sqlite3_nif.o

This produces sqlite3_nif_nif_init instead of nif_init. Register it:

// driver_tab_ios.c
void *sqlite3_nif_nif_init(void);

ErtsStaticNif erts_static_nif_tab[] = {
    // ... existing entries ...
    {sqlite3_nif_nif_init, 0, THE_NON_VALUE, NULL},
    {NULL,                 0, THE_NON_VALUE, NULL}
};

Erlang Distribution

The BEAM on a physical device supports full Erlang distribution — mix dala.connect, Dala.Test.*, hot code push, and direct IEx RPC all work the same as on the simulator.

The node name is determined at startup by walking the device's network interfaces in priority order:

PriorityConnectionNode nameRequires
1WiFi / LAN<app>_ios@10.0.0.xSame network as Mac
1Tailscale<app>_ios@100.x.x.xTailscale running on both devices
1Personal Hotspot<app>_ios@172.20.10.1Mac connected to iPhone's hotspot
2USB cable only<app>_ios@169.254.x.xCable plugged in, no WiFi
3None<app>_ios@127.0.0.1No network — unreachable without iproxy

WiFi is checked before USB. This is the opposite of what you might expect. The reason: if the node started with a USB link-local address and you later unplug the cable, the Mac can no longer reach that address and distribution dies. With WiFi taking priority, the node IP stays stable whether the cable is in or out.

If both WiFi and USB are connected when the app launches, the node will use the WiFi IP. Plugging in USB afterwards does not change it.

USB only (no WiFi): the node falls back to the link-local address and mix dala.connect finds it the same way — no difference in that workflow.

The node name is still fixed at app launch

The BEAM picks an IP once, at startup, and does not change it while running. Connecting or disconnecting WiFi after launch has no effect. The improvement is that WiFi is now chosen over USB at launch time, so the node survives USB reconnects without needing a restart.

If distribution isn't working: force-quit the app and relaunch it so it picks up the current network state. mix dala.connect will find it automatically.

Plug in the cable. No configuration needed.

mix dala.connect

The iPhone presents a USB networking interface on the Mac (typically en11) with a 169.254.x.x link-local address. The device's in-process EPMD and dist port both bind 0.0.0.0, so the Mac can reach them directly at that address.

WiFi

Works automatically when Mac and iPhone are on the same network — no cable, no setup.

mix dala.connect

If it doesn't connect, check: was the app last launched with USB plugged in? If so, force-quit and relaunch the app on the iPhone (without USB), then run mix dala.connect again.

Limitation: public WiFi (coffee shops, hotels, conferences) and many corporate networks enable client isolation, which blocks device-to-device traffic. If the device isn't found, use USB or Tailscale.

Tailscale (any network, including cellular)

Tailscale is a mesh VPN built on WireGuard. Once installed, devices on the same Tailscale account can reach each other on any network — same WiFi, different WiFi, cellular, corporate network. It's free for personal use.

Setup (one time):

  1. Install the Tailscale app on your Mac and iPhone.
  2. Sign in to the same Tailscale account on both.
  3. On the iPhone: open the Tailscale app and enable the VPN.

Usage:

mix dala.connect   # works the same — no change to the workflow

The BEAM detects the Tailscale interface (100.x.x.x) at startup and registers the node there. The Mac reaches it directly over the WireGuard tunnel — Tailscale's servers are only involved in the initial connection handshake.

Important: Tailscale must be active on the iPhone before the app launches. The node name is fixed at BEAM startup. If you enable Tailscale after the app is already running, restart the app.

Personal Hotspot

Connect the Mac to the iPhone's Personal Hotspot (Settings → Personal Hotspot). The iPhone's hotspot address (172.20.10.1) is detected automatically — no setup beyond connecting the Mac to the hotspot WiFi.

Finding the node name manually

If you're unsure what address the BEAM registered under, query the device's EPMD directly (USB must be connected):

# Substitute your device's link-local IP (shown in ifconfig as 169.254.x.x on en11)
elixir -e "
{:ok, s} = :gen_tcp.connect({169, 254, 235, 134}, 4369, [:binary, active: false], 3000)
:gen_tcp.send(s, <<0, 1, ?n>>)
{:ok, <<_port::32, names::binary>>} = :gen_tcp.recv(s, 0, 3000)
:gen_tcp.close(s)
IO.puts(names)
"
# → name smoke_test_ios at port 9101

Pitfalls

"No provider was found" from devicectl

Benign. xcrun devicectl always prints this warning when the provisioning profile database lookup fails, even when the command succeeds. Ignore it; check the exit code and the "App installed" line instead.

Bundle ID with underscores rejected

com.dala.dala_qa is rejected when creating provisioning profiles (Xcode generates an invalid scheme name like XC com dala dala_qa). Use only dots and alphanumeric characters: com.dala.dalaqa.

No crash log after silent crash

The BEAM crashing inside erl_start before signal handlers are registered produces no entry in the system crash logs and no ERL_CRASH_DUMP. The app simply vanishes. To diagnose: redirect stdout/stderr to a file in Documents before calling erl_start, and add sentinel files at key points:

int fd = open("/path/to/Documents/beam_stdout.log", O_WRONLY|O_CREAT|O_TRUNC, 0644);
dup2(fd, STDOUT_FILENO);
dup2(fd, STDERR_FILENO);
close(fd);

Use xcrun devicectl device copy from ... --domain-type appDataContainer to pull Documents off the device without needing Xcode.

system() unavailable on iOS

OTP's erlexec.c and heart.c call system(), which does not exist on iOS. The linker will fail or the binary will crash. Patch both files with:

#if !(defined(__APPLE__) && defined(TARGET_OS_IPHONE) && TARGET_OS_IPHONE)
    // original system() call
#endif

asn1rt_nif.a must use STATIC_ERLANG_NIF

If asn1rt_nif.c is compiled without -DSTATIC_ERLANG_NIF, it exports nif_init instead of asn1rt_nif_nif_init. The linker will include the object but the symbol won't be found by driver_tab_ios.c's declaration. Rebuild with the flag.

libmicro_openssl.a (historical — no longer needed)

Earlier setups linked against libmicro_openssl.a to satisfy MD5Init/ MD5Update/MD5Final symbols. With --without-ssl OTP, nothing in the linked code path references them — libbeam.a's own erts_md5_* covers everything that does get called. The lib is dropped from the LIBS list entirely; do not re-add it.

dlopen of .so NIFs silently fails at runtime

On the device, dlopen for a .so NIF does not crash the BEAM immediately. It logs a warning (The on_load function for module X returned: {:error, :load_failed}) and the connection pool start-up fails later with a seemingly unrelated error. If you see UndefinedFunctionError from Exqlite.Sqlite3NIF.open/2, the NIF was never loaded — the .so does not exist and the static registration is missing.

EPMD error: Protocol 'inet_tcp': register/listen error: econnrefused

This is logged to BEAM stdout (captured in beam_stdout.log if you redirect it). It means erl_start found no EPMD on port 4369. On the device the Mac's EPMD is not reachable. Fix: in-process EPMD thread (see step 5 above).

OTP bundle size / watchdog timeout

The default OTP lib/ includes 67 MB of lib/erlang/ (duplicated under a different path) and dozens of unused applications. Strip aggressively before bundling:

  • Remove lib/erlang/ (it's a duplicate of the top-level layout)
  • Remove unused OTP apps: common_test, diameter, edoc, erl_interface, eunit, inets, mnesia, parsetools, public_key, reltool, syntax_tools, tools, xmerl

Target: lib/ under 40 MB, total .app/otp/ bundle under 50 MB.

ERTS version vs OTP release version

OTP-28.1 installs as erts-16.1, not erts-16.3 (which is OTP-28.3). The OTP_RELEASE passed to -boot must match the actual release number. Always auto-detect:

ERTS_VSN=$(ls "$OTP_ROOT" | grep '^erts-' | sort -V | tail -1)

And pass matching compile-time defines:

-DERTS_VSN=\"erts-16.1\" -DOTP_RELEASE=\"28\"

BINDIR must exist even though binaries can't run

The BEAM reads the BINDIR environment variable and checks the directory exists. Even though no binary in it can be exec'd on iOS, the directory itself must be present or the BEAM aborts early. Create it:

mkdir -p "$OTP_BUNDLE/$ERTS_VSN/bin"

Root-level OTP assets not bundled

The bundle step copies lib/, releases/, and the app BEAM directory — but not root-level files sitting directly in $OTP_ROOT. If your Elixir code references assets via System.get_env("ROOTDIR") (e.g. logo images), those files must be explicitly copied into the .app/otp/ root during bundling:

for f in "$OTP_ROOT"/*.png "$OTP_ROOT"/*.jpg; do
    [ -f "$f" ] && cp "$f" "$OTP_BUNDLE/"
done

On the simulator ROOTDIR points to the Mac's /tmp/otp-ios-sim which is writable and populated by the deployer, so assets are always there. On the device they must be in the bundle.

Crash dump is written to Documents, not /tmp

On the device, /tmp is the app's sandbox temporary directory and is cleared on each install. Set ERL_CRASH_DUMP to a path inside Documents:

snprintf(crash_dump, sizeof(crash_dump), "%s/dala_erl_crash.dump", docs_dir);
setenv("ERL_CRASH_DUMP", crash_dump, 1);

Checking the device after a crash

Pull Documents off the device (no Xcode needed):

xcrun devicectl device copy from \
  --device <DEVICE_UUID> \
  --domain-type appDataContainer \
  --domain-identifier com.dala.dalaqa \
  --source Documents \
  --destination /tmp/dalaqa_docs

List files on device without pulling:

xcrun devicectl device info files \
  --device <DEVICE_UUID> \
  --domain-type appDataContainer \
  --domain-identifier com.dala.dalaqa

System crash logs (Jetsam events, signal crashes):

xcrun devicectl device info files \
  --device <DEVICE_UUID> \
  --domain-type systemCrashLogs