Acknowledged
CIQQA-4579

Simulator segfaults on Communications.transmit over the tethered (ADB) connection — SDK 9.1.0 and 9.2.0, Linux

Summary

With a tethered (ADB) data connection established, the Connect IQ simulator crashes with SIGSEGV the moment a watch app calls Communications.transmit. No byte is written to the socket. Reproducible with a one-line addition to your own samples/Comm, on both SDK 9.1.0 and 9.2.0.

The opposite direction — phone → watch — works correctly over the very same connection, so the transport and the session are demonstrably healthy when the crash happens.

Environment

  • SDK 9.1.0 (2026-03-09) and 9.2.0 (2026-06-09) — both affected
  • Linux (Ubuntu 22.04 container on a Fedora host)
  • Devices tried: vivoactive3 (Comm sample), fr965 (our own app)
  • Peer: a plain TCP server on 127.0.0.1:7381, in place of adb forward tcp:7381 tcp:7381

Minimal reproduction

  1. Listen on TCP 127.0.0.1:7381. Any server will do — this is the host side of adb forward tcp:7381 tcp:7381, and the simulator is the client. Keep it listening for the whole session.
  2. Start the simulator, then Connection → start the ADB connection. The simulator connects.
  3. Take samples/Comm unmodified and add one line to CommApp.onStart:
public function onStart(state as Dictionary?) as Void {
    Communications.transmit("Hello World.", null, new $.CommListener());
}
  1. Run it.

Expected: the payload is serialized and written to the socket.
Actual: the simulator dies with SIGSEGV within a second. Zero bytes are written.

Left unmodified — the sample only transmits from its menu — the same binary stays alive indefinitely while connected.

What we verified is NOT the cause

Each of these was tested and crashes identically:

  • Payload type — String (Comm sample) vs Dictionary (our app): both crash.
  • The options argument — null (as your sample passes) vs {} (as the docs prescribe): both crash.
  • Stale or invalid session — transmit on a session where a phone → watch message had just been accepted and displayed: crashes.
  • Our own code — reproduces on samples/Comm plus one line.
  • A recent regression — 9.1.0 and 9.2.0 both crash.

Crash analysis

Identical stack across our app and the patched sample (stripped binary, offsets from the ELF base):

#0  simulator + 0xd5fcc8
#1  simulator + 0x40d967
#2  simulator + 0x396ba6

The faulting function:

movl $0x0,(%rsi)      ; *out = 0
test %rdi,%rdi
je   <return>         ; guard 1: null object -> clean return
call <...>            ; ~ buf->data()
mov  %rax,%r12
call <...>            ; ~ buf->size()
cmp  $0x3,%eax
jbe  <bail>           ; guard 2: size <= 3 -> clean return
=> movzwl (%r12),%edx ; SIGSEGV
   movzbl 0x2(%r12),%ecx
   rol   $0x8,%dx
   shl   $0x8,%edx
   or    %ecx,%edx    ; 24-bit big-endian length

Both guards pass — the object is non-null and reports size > 3 — yet %r12 holds 0x8e432fa067e9a719, which is not a mapped address. data() therefore returns a wild pointer while size() returns a plausible value: the buffer object is in an inconsistent state (use-after-free, or read before initialisation). Nothing has been received on the socket at that point, so this is not parsing of peer input.

The opposite direction works, which makes this a narrow bug

To be certain the harness was faithful, we implemented the phone side using the serialization classes shipped in your own companion SDK (ciq-companion-app-sdk, com.garmin.monkeybrains.serialization). Sending a correctly serialized object to the simulator over the same socket works: registerForPhoneAppMessages fires and samples/Comm displays the payload, and the connection stays open.

So: phone → watch is fine; watch → phone segfaults.

Additional observations (same transport, probably related, listed for completeness)

  • If the peer stops listening, the simulator crashes rather than reporting a dropped link.
  • After a few connect/reject cycles, the simulator dies with SIGABRT instead (stack entirely in libc) — a different site.
  • Occasionally the simulator freezes instead of crashing (no coredump, the process must be killed).
  • On both SDKs, the bin/connectiq launcher aborts (SIGABRT) at startup on this machine, while running bin/simulator directly works. Possibly unrelated; mentioned in case it helps.
  • A malformed payload sent phone → watch is answered by an immediate connection close. We believe this explains the long-standing "Get Comm sample working" report ("ADB Disconnected" when messaging the watch): the payload was not a valid serialized object, and the close is the simulator’s rejection — not a transport failure.

Impact

Phone ↔ watch messaging cannot be exercised in the simulator in the watch → phone direction. For developers targeting devices they do not own, the simulator is the only option, so this blocks testing of any app that talks to a companion app.

  • Thanks a lot! I can confirm that this fix works (with 9.2.0/macOS 26.6.2)

  • Reproed/patched on Mac, 9.2.0:

    Verify before patching

    APP="$HOME/Library/Application Support/Garmin/ConnectIQ/Sdks/connectiq-sdk-mac-9.2.0-2026-06-09-92a1605b2/bin/ConnectIQ.app"
    SIM="$APP/Contents/MacOS/simulator"
    # exact build check
    echo "391cfc8afe41137aea734cc03f31f6d645ace1f5cbafb67e96c55a1507682b3f $SIM" | shasum -a 256 -c -
    # and/or just the two words — must print f40302aa and e00302aa
    xxd -s $((0x1797448)) -l 4 -p "$SIM"; xxd -s $((0x1797458)) -l 4 -p "$SIM"

    If these don't match, STOP, your version is not the same

    Patch

    cp -R "$APP" "$APP.orig"
    printf '\x54\x08\x40\xf9' | dd of="$SIM" bs=1 seek=$((0x1797448)) conv=notrunc
    printf '\xe0\x03\x14\xaa' | dd of="$SIM" bs=1 seek=$((0x1797458)) conv=notrunc
    
    # confirm — must print 540840f9 and e00314aa
    xxd -s $((0x1797448)) -l 4 -p "$SIM"; xxd -s $((0x1797458)) -l 4 -p "$SIM"

    Re-sign

    codesign --force --sign - "$APP/Contents/MacOS/libANT.dylib"
    codesign --force --sign - "$APP"

    (libAnt.dylib is Garmin-signed and depended on by the app so needs to be resigned as well)

    I tested this works with a basic hello world companion app connected to the simulator ADB, no guarantees beyond that.

  • Hey ho. So after way too much time and way too many AI tokens spent: I've narrowed down the issue. It also doesn't just seem to affect the Linux version, but the Windows version as well (though I haven't tested the windows simulator outside of wine).

    Essentially: There's an issue in the binary (pinned to 9.2.0 at the time of writing this post), in an event handler. The surrounding context is a switch statement, and we're concerned about case-5:

    Before we even get here, the simulator correctly serializes the frame to be sent and creates an event envelope with the following layout:

    Linux, 64-bit:
    + 0x00 16-byte Connect IQ application UUID
    + 0x10 64-bit managed serialized-payload handle
    + 0x18 32-bit transmit transaction id.

    Windows, 32-bit:
    +0x00 16-byte Connect IQ application UUID
    +0x10 32-bit managed serialized-payload handle
    +0x14 32-bit transmit transaction ID

    The case-5 handler then calls a function that expects a managed payload handle and resolves its data pointer and size.
    However, because of a bug, the function doesn't receive the managed payload handle, and instead receives the address of the envelope, leading it to try to resolve the application UUID words as a pointer and a size. This then raises the access violation we see. On wine, the symptom is slightly different, where it basically just stalls the transmit queue forever, and fills up the transmit queue after 3 send attempts.

    For the specific Connect IQ SDK version 9.2.0-2026-06-09-92a1605b2, the affected offsets and bytes are:

    Linux:

    Event handler: Virtual Address 0x00396b50
    Case-5 area: argument setup at 0x00396b98, call at 0x00396ba1
    Called transmit wrapper: 0x0040d930

    Windows (or more accurately wine):

    Event Handler: Virtual Address 0x00499590
    Case-5 area: VA 0x004996e9, relative VA 0x000996e9, file offset 0x00098ae9
    Called transmit wrapper: 0x004f2060

    These are link-time virtual addresses. For PIE/ASLR processes, add the module’s runtime load bias. File offsets are unaffected.

    A fix

    We can fix this issue ourselves by patching the binary to pass the correct envelope fields instead.

    For my specific version of the Connect IQ SDK that means:

    Linux Diff
    The semantic bug is visible at 0x00396b98:

    mov rdx, r13       ; BUG: r13 is the entire event envelope
    mov esi, r12d      ; transaction ID, already loaded from envelope+0x18
    mov rdi, rbp       ; connection object
    call 0x0040d930


    Inside 0x0040d930, the unpatched code forwards that envelope to the managed-buffer parser:
    mov rbx, rdi
    mov r12d, esi
    mov rdi, rdx       ; parser argument = envelope
    mov rbp, rdx       ; retained handle = envelope
    lea rsi, [rsp+0x4]
    call 0x00d5fc90


    Our same-length patch at 0x0040d951 is:

    mov rbx, rdi
    mov r12d, esi
    mov rbp, [rdx+0x10] ; extract managed payload handle
    push rbp
    pop  rdi            ; parser argument = managed payload handle
    lea rsi, [rsp+0x4]
    call 0x00d5fc90


    Binary patch at ELF VA/file offset 0x0040d951:
    OLD:
    48 89 FB 41 89 F4 48 89 D7 48 89 D5 48 8D 74 24 04 E8 29 23 95 00

    NEW:
    48 89 FB 41 89 F4 48 8B 6A 10 55 5F 48 8D 74 24 04 E8 29 23 95 00
    The original and replacement are both 22 bytes. The original signature occurred exactly once in our pinned binary.


    Windows Diff
    Original at VA 0x004996e9:
    push esi                    ; envelope pointer
    push dword ptr [ebp-0xd0]   ; transaction ID copied from envelope+0x14
    mov  ecx, edi
    call 0x004f2060

    Patched:

    push dword ptr [esi+0x10]   ; managed payload handle
    push dword ptr [esi+0x14]   ; transaction ID
    mov  ecx, edi
    call 0x004f2060
    nop


    The replacement moves the call one byte earlier, so its relative displacement changes by one. The trailing nop preserves all subsequent addresses.
    Binary patch at file offset 0x00098ae9:

    OLD:
    56 FF B5 30 FF FF FF 8B CF E8 69 89 05 00

    NEW:
    FF 76 10 FF 76 14 8B CF E8 6A 89 05 00 90
    This exact original signature occurs once in the unpatched Windows binary.


    As for the other issues mentioned by @LGnap, I can't speak much to them...
    However, In Android Connect IQ SDK 2.4.0, the tethered receive path constructs incoming simulator messages with IQApp("", "Simulator App", 1) instead of the expected application UUID. Because listener lookup uses that empty ID, additionally registering ConnectIQ.registerForAppEvents(device, IQApp(""), appListener) fixes receive dispatch for me.

    As of now I have only tested a very simple PING, PONG message format, but that, at least, seems to work...

  • I can reproduce the same crash on Linux with the ADB tethered connection enabled, and Communications.transmit triggers a SIGSEGV immediately in both SDK 9.1.0 and 9.2.0. This looks like a simulator regression in the ADB transport path rather than an application-level issue, so the acknowledgement is appreciated.

  • A few more measurements since the original report, in case they help narrow the fault. Same environment: SDK 9.2.0, Linux, tested on venu3 and fr965.

    1. The receive path is healthy, and stays healthy, on the very same connection

    With a correctly serialised object pushed phone → watch over the tethered link, the watch app receives it, renders it, and the connection stays ESTABLISHED indefinitely. I drove this from a plain Python TCP server standing in for the phone — no Android, no companion app — and the watch rendered the pushed list.

    So the session is demonstrably alive at the moment transmit takes the process down. Whatever is wrong is on the send path, not in the connection or the handshake.

    2. A malformed payload is answered by an immediate RST, with no diagnostic

    Sending 35 bytes of plain JSON — rather than a serialised object — over an established connection produces ECONNRESET on the peer within the same second. Not a clean close, not an error, not a log line: the socket is simply torn down. The simulator itself survives.

    Two things follow. First, the receive path can clearly tell a valid payload from an invalid one, so the teardown is a decision, not a parse crash. Second, from the phone's side an RST is indistinguishable from adb dropping the link, so it sends developers hunting in the transport layer for a problem that is in their encoder. An error, or even a silent discard, would cost them a lot less than a reset. I would file that as a separate usability bug if you would find it useful.

    Worth noting how uninformative the symptom is: the same « ADB Disconnected » is reported by someone running your OOTB Comm sample on both ends — so their payload came from sendMessage and was serialised by the SDK. Their cause is not this one, and remains unknown after six years. That one alert covers at least two unrelated faults.

    3. The harness is faithful to what a real companion app writes

    To be sure I was not testing my own misunderstanding, I proxied a real Android companion app against the simulator and captured what the SDK actually puts on the wire. Decoding those bytes gives the expected values, and re-encoding them reproduces the capture byte for byte. The messages carry no length prefix — objects are self-delimiting and written back to back.

    In other words, the payloads in the original report are the same shape as the SDK's own.

    4. One more, on the companion side

    Not your bug, but it costs anyone building a tethered rig an afternoon, so it belongs next to this report: in IQConnectType.TETHERED, ConnectIQ.sendMessage writes to the socket synchronously, on the calling thread. Called from the main looper — which is what you naturally do when answering an inbound message — Android throws NetworkOnMainThreadException and the message is lost silently. In WIRELESS it never happens, because that strategy hands off to Garmin Connect Mobile instead of touching a socket. It would be kind to mention the threading requirement in the companion SDK docs.

    Workaround, and tools

    Until transmit is fixed, the watch → phone direction can be routed through System.println behind a build annotation and read off the monkeydo console. It is ugly, but it restores a full-duplex rig on a device one does not own.

    I have published the pieces under Apache-2.0, so nobody else has to rediscover any of this: https://github.com/lgnap/connectiq-tethered-rig — a Python implementation of the serialisation format, a fake phone that impersonates the peer on 7381, and the console relay. It contains no Garmin code; the SDK is yours to supply.

    Happy to run further experiments on this setup if any specific measurement would help.