jsyn MRE: WebRTC outbound audio intermittently never starts; Conference.close() intermittently hangs
Minimal reproduction of two issues we hit replacing Kurento with jsyn in a voice-agent backend. There is no application code here: one Micronaut 4.9.0 app, jsyn, and a headless Chromium peer (Playwright) doing real ICE, DTLS-SRTP and Opus.
Both issues are intermittent in this harness. In our production app (same jsyn, same machine) issue 1 happened on nearly every call and issue 2 on every hang-up.
One conference per call, two participants:
browser --WebRTC/Opus--> "caller" (WebRtcParticipant) --matrix--> "agent" (NativeParticipant) .read() -> speech-to-text ✅ always works
TTS -> .write() "agent" (NativeParticipant) --matrix--> "caller" (WebRtcParticipant) --WebRTC--> browser ❌ often nothing arrives
Matrix: connect("caller","agent") + connect("agent","caller").
On an affected call:
- ICE and DTLS-SRTP connect (
connectionState=connected), and the browser sends its mic. - browser → jsyn works. The native participant's egress ring delivers the browser's fake-mic tone (thousands of non-silent samples).
- jsyn → browser sends nothing. Chrome's
inbound-rtpshowspacketsReceived=0for the entire call. - The native ingress ring is being consumed:
bytesWrittengrows at the real-time rate,ingressRingUtilizationstays at one or two frames, andingressOverruns=0. jsyn takes the audio and it never reaches the wire.
It's all or nothing per call. A good call gets audio within a second (40 to 50
packets by the first sample); a bad call gets zero packets ever. It happens with
every native format, whether the matrix is wired before or after ICE connects,
and also with a plain WebRTC self-route (connect("caller","caller"), the shape
of jsyn's own WebRtcMediaE2eIT.audioFlowsBackToTheBrowser). So this looks like the
WebRTC participant's send path intermittently never starting, not something
specific to native participants.
Separately, a native → native control (two native participants in one conference) passed on every run we did.
After a live WebRTC call, Conference.close() blocks forever in the native call:
at com.synauson.jsyn.internal.NativeBridge.terminateConference(Native Method)
at com.synauson.jsyn.participant.Conference.lambda$new$0(Conference.java:64)
at java.base/jdk.internal.ref.CleanerImpl$PhantomCleanableRef.performCleanup(CleanerImpl.java:176)
at java.base/jdk.internal.ref.PhantomCleanable.clean(PhantomCleanable.java:96)
at com.synauson.jsyn.internal.NativeResource.close(NativeResource.java:56)
In our app it was hit on every disconnect. Because we closed on a Netty event-loop thread, it froze every later WebSocket on that loop. We now close on a separate thread with a timeout, which leaks the thread and the native conference.
The test follows our production teardown: the peer hangs up, the ICE subscription
is cancelled, the NativeParticipant is closed while its I/O thread is still
running, then conference.close().
./gradlew test runs 26 live calls. Every failed call in issue 1 had
packetsReceived=0 at the browser while ICE/DTLS were connected.
| Test | 1.0.0 (release) |
1.0.0-SNAPSHOT (natives 20260806.015015-10) |
|---|---|---|
| control: native → native | 1/1 pass | 1/1 pass |
| issue 1: native → WebRTC, 3 formats × wired before/after connect × 3 | 10/18 no audio | 12/18 no audio |
| ↳ matrix wired before ICE connects (as our app does) | 6/9 no audio | 6/9 no audio |
| ↳ matrix wired after ICE connects | 4/9 no audio | 6/9 no audio |
| issue 1b: WebRTC self-route (echo) | 1/3 no audio | 2/3 no audio |
issue 2: close() > 10 s after a WebRTC call |
0/4 hung (did hang in an earlier 1.0.0 run) |
1/4 hung |
Per format, over both versions (12 calls each): µ-law 8 kHz 6/12 no audio, PCM16 8 kHz 7/12, PCM16 48 kHz 9/12. We don't read that as format-dependent.
Full logs: each failing assertion prints the browser snapshot plus the native ring counters, and a hung close logs its stack.
- Windows 10 Pro 22H2 (10.0.19045), x86_64
- GStreamer 1.26.7 (MSVC x86_64 runtime), on
PATH - JDK 25.0.2 (Gradle toolchain), Gradle 9.7.1
- Micronaut 4.9.0 (
io.micronaut.applicationGradle plugin 4.6.2), matching our production app - jsyn + jsyn-natives-windows
1.0.0, and1.0.0-SNAPSHOT(-PjsynVersion=1.0.0-SNAPSHOT) - Chromium 129.0.6668.29 via Playwright 1.47.0 (the version jsyn's own tests use)
- jsyn config: STUN
stun://stun.l.google.com:19302, WebRTC jitter buffer 200 ms, no VAD / Smart-Turn models
We haven't tried Linux: the released jsyn-natives-linux:1.0.0 needs GLIBC_2.43
and won't load on current distros.
Prerequisites: GStreamer ≥ 1.26 on PATH, and Synauson Nexus credentials.
export NEXUS_USERNAME=... NEXUS_PASSWORD=...
# Windows (Git Bash): export PATH="/c/Program Files/gstreamer/1.0/msvc_x86_64/bin:$PATH"
./gradlew installPlaywrightBrowsers # one-time: downloads the Chromium Playwright 1.47 drives
./gradlew test # against jsyn 1.0.0
./gradlew test -PjsynVersion=1.0.0-SNAPSHOTThe interesting output is the [bug1 ...], [bug1b ...] and [bug2 ...] lines.
Every test logs its outcome, pass or fail.
| File | What |
|---|---|
src/main/java/mre/MediaEngine.java |
The one JSyn per JVM, configured as in production |
src/test/java/mre/JsynWebRtcOutboundTest.java |
The control and the issue 1 / 1b / 2 tests |
src/test/java/mre/BrowserPeer.java |
Headless Chromium peer (fake mic tone; remote track on an Audio sink so Chrome decodes it, as in jsyn's WebRtcBrowserPeer) |
src/test/java/mre/ToneGenerator.java |
440 Hz tone in µ-law / PCM16 |