aboutsummaryrefslogtreecommitdiff
path: root/app/src/main/java/invalid/lena/scrcpy/Session.java
diff options
context:
space:
mode:
authorLena <lena@omega>2026-08-01 00:00:00 +0000
committerLena <lena@omega>2026-08-01 00:00:00 +0000
commit852a8a00273c9128efeed0217a8e5d3fcd8bf780 (patch)
treec72f959731fa3c7c809cf1a0222363b6c9c9b03b /app/src/main/java/invalid/lena/scrcpy/Session.java
parentf379b93d52bf0dbd3816ec3f64d165314771d2a6 (diff)
downloadscrcpy-android-852a8a00273c9128efeed0217a8e5d3fcd8bf780.tar.gz
app: harden mirroring lifecycle and state
Diffstat (limited to 'app/src/main/java/invalid/lena/scrcpy/Session.java')
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Session.java377
1 files changed, 278 insertions, 99 deletions
diff --git a/app/src/main/java/invalid/lena/scrcpy/Session.java b/app/src/main/java/invalid/lena/scrcpy/Session.java
index a427d63..294b27d 100644
--- a/app/src/main/java/invalid/lena/scrcpy/Session.java
+++ b/app/src/main/java/invalid/lena/scrcpy/Session.java
@@ -5,53 +5,86 @@ import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.Surface;
-import java.io.File;
import java.io.IOException;
+import java.net.SocketTimeoutException;
import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
// One mirroring session: owns the Adb connection, the spawned scrcpy
// server, the three streams, sinks, and the controller. start() and
// stop() are idempotent and may be called from any thread.
//
-// Listener contract:
-// onConnected(w,h) - wire open, frames flowing. Fires on the first
-// connect and again after each successful auto-reconnect.
-// onReconnecting() - a live link dropped; the bring-up ladder is rerun.
-// Followed by onConnected (recovered) or onError (gave up).
-// onError(t) - fatal: retries exhausted, or non-retriable failure.
-// Always followed by onStopped().
-// onStopped() - final state. Fires exactly once per session, whether
-// user stop() or retries-exhausted.
+// The Listener contract is stated once, on the interface below.
//
-// Auto-reconnect: after a successful connect the video socket is watched;
-// if it ends mid-session (target sleep, Wi-Fi blip, server crash) the
-// session reruns the bring-up ladder instead of freezing. Only when that
-// ladder is exhausted does it give up via onError + onStopped.
+// Two retry budgets, because there are two different failures.
+// BACKOFF_MS covers bring-up, where the target is not answering yet.
+// RECONNECT_BACKOFF_MS covers a link that came up and then died (target
+// sleep, Wi-Fi blip, server crash) - and also the case where it dies
+// every time for the same permanent reason, which is why that budget is
+// finite. Either budget running out ends the session via onError +
+// onStopped rather than retrying forever.
//
// Surface-readiness race: Mirror.surfaceCreated builds the Session and
-// calls start(), but Mirror.surfaceChanged (which carries the view
-// dimensions) can fire before run() has finished constructing Controller.
-// setViewSize() therefore stashes the value and applies it as soon as
-// the Controller is available.
+// calls start(), but the viewport can be measured before run() has
+// finished constructing Controller. setViewport() therefore stashes the
+// value and applies it as soon as the Controller is available.
public final class Session {
public interface Listener {
// Fires the first time the server reports a session-meta packet
// (wire open, frames about to flow), and again after each
// successful auto-reconnect. Useful for the activity's status bar.
- default void onConnected(int w, int h) {}
+ default void onConnected(long geometryVersion, int w, int h) {}
// A previously-live link dropped and we are bringing it back up.
// Followed by onConnected (recovered), onError (gave up), or
// onStopped alone (user stop() raced the reconnect).
default void onReconnecting() {}
+ // Fatal: a retry budget ran out, or the failure was not
+ // retriable. Always followed by onStopped().
void onError(Throwable t);
+ // Final state. Fires exactly once per session, whether from
+ // stop() or from a budget running out.
void onStopped();
}
- // Bring-up retry budget. Total worst-case wait ~ sum of these
- // delays + per-attempt bring-up time. Keep below e2e.sh's deadline.
- private static final long[] BACKOFF_MS = {0L, 1_500L, 5_000L};
+ // Bring-up retry budget, for a target that is not answering yet.
+ // Total worst-case wait ~ sum of these delays + per-attempt bring-up
+ // time.
+ private static final long[] BACKOFF_MS =
+ {0L, 1_500L, 5_000L, 10_000L, 20_000L};
+
+ // Reconnect budget, for a link that dropped AFTER it came up. This is
+ // a different failure from bring-up: something that worked has
+ // stopped working, and it may be permanent (target has no decoder for
+ // the selected codec, frame larger than the decoder's input buffer).
+ // Without a budget those spin here forever at zero delay, re-pushing
+ // the server jar and respawning app_process on the target on every
+ // pass, with nothing shown to the user.
+ //
+ // A connection that stays up for HEALTHY_MS resets the budget, so a
+ // long session that blips occasionally never exhausts it, while a
+ // deterministic failure walks the ladder and then gives up.
+ private static final long[] RECONNECT_BACKOFF_MS =
+ {1_000L, 2_000L, 5_000L, 10_000L, 15_000L};
+ // Comfortably longer than STALL_MS + STALL_POLL_MS. If it were not,
+ // a stall-detected drop would always look "healthy" (a stall is only
+ // declared after STALL_MS of silence, so the connection is at least
+ // that old by then), the budget would reset on every stall and the
+ // ladder would never be reached for the one failure it exists for.
+ private static final long HEALTHY_MS = 120_000L;
+
+ // Stall watchdog. adb streams have no read timeout - AdbStream.read()
+ // waits on its queue until data arrives or the stream is closed - so a
+ // target that disappears without closing the socket (Wi-Fi dropping
+ // mid-frame, NAT rebinding, a carrier idle timeout on a VPN link)
+ // leaves the readers parked and the session frozen forever with no
+ // reconnect. Poll for silence on the audio socket instead; see
+ // AudioStream.isStarted() for why audio and not video.
+ private static final long STALL_MS = 30_000L;
+ private static final long STALL_POLL_MS = 5_000L;
+ private static final long BRING_UP_DEADLINE_MS = 90_000L;
+ private static final long STOP_JOIN_MS = 10_000L;
private final Context ctx;
private final Adb adb;
@@ -59,23 +92,31 @@ public final class Session {
private volatile Surface surface;
private final Listener listener;
- // Lightweight counters for the in-app status overlay; readers reach
- // into the sinks/controller directly.
- public long videoFrames() { VideoSink v = videoSink; return v == null ? 0 : v.frames; }
- public long audioFrames() { AudioSink a = audioSink; return a == null ? 0 : a.frames; }
- public String lastEvent() { Controller c = controller; return c == null ? "(idle)" : c.lastEvent; }
-
private Server server;
private VideoStream videoStream;
- private VideoSink videoSink;
+ private volatile VideoSink videoSink;
private AudioStream audioStream;
private AudioSink audioSink;
private ControlStream controlStream;
- private Controller controller;
- private MuxRecorder recorder;
+ private volatile Controller controller;
private Thread runner;
private volatile boolean stopped;
- private volatile int pendingViewW, pendingViewH;
+ private boolean stoppedNotified;
+ private long geometryVersion;
+ private volatile Viewport pendingViewport;
+
+ private static final class Viewport {
+ final long version;
+ final int x, y, w, h;
+
+ Viewport(long version, int x, int y, int w, int h) {
+ this.version = version;
+ this.x = x;
+ this.y = y;
+ this.w = w;
+ this.h = h;
+ }
+ }
// Counted down by the video reader when its loop exits; the session
// thread parks on it for the live duration of a connection. Swapped
@@ -91,17 +132,48 @@ public final class Session {
}
public synchronized void start() {
- if (runner != null) return;
+ if (runner != null || stopped) return;
runner = new Thread(this::run, "session");
runner.start();
}
- public synchronized void stop() {
- if (stopped) return;
- stopped = true;
- Log.i("session: stop");
+ public boolean stop() {
+ Thread r;
+ boolean notify;
+ synchronized (this) {
+ if (!stopped) {
+ stopped = true;
+ Log.i("session: stop");
+ }
+ notify = !stoppedNotified;
+ stoppedNotified = true;
+ r = runner;
+ }
+ if (r != null && r != Thread.currentThread()) r.interrupt();
+ adb.abort();
tearDownInstalled();
- if (listener != null) listener.onStopped();
+ endSignal.countDown();
+ boolean joined = true;
+ if (r != null && r != Thread.currentThread()) {
+ boolean interrupted = false;
+ long deadline = monotonicMs() + STOP_JOIN_MS;
+ while (r.isAlive()) {
+ long remaining = deadline - monotonicMs();
+ if (remaining <= 0) {
+ joined = false;
+ break;
+ }
+ try {
+ r.join(remaining);
+ } catch (InterruptedException e) {
+ interrupted = true;
+ }
+ }
+ if (interrupted) Thread.currentThread().interrupt();
+ }
+ if (!joined) Log.e("session: runner did not stop within %d ms", STOP_JOIN_MS);
+ if (notify && listener != null) listener.onStopped();
+ return joined;
}
// ---- input forwarding (Controller stays internal) ----
@@ -121,32 +193,26 @@ public final class Session {
if (c != null) c.onBack();
}
- public void setViewSize(int w, int h) {
- pendingViewW = w; pendingViewH = h;
+ public void onHome() {
Controller c = controller;
- if (c != null) c.setViewSize(w, h);
+ if (c != null) c.onHome();
}
- // ---- recording (delegates to MuxRecorder) ----
-
- public void armRecording(File out) {
- MuxRecorder r = recorder;
- if (r == null) {
- Log.w("session: armRecording before bring-up - ignored");
- return;
- }
- r.arm(out);
+ public void onRecents() {
+ Controller c = controller;
+ if (c != null) c.onRecents();
}
- // Returns true if a file was actually written (see MuxRecorder.stop).
- public boolean stopRecording() {
- MuxRecorder r = recorder;
- return r != null && r.stop();
+ public void syncClipboard() {
+ Controller c = controller;
+ if (c != null) c.syncLocalClipboard();
}
- public boolean isRecording() {
- MuxRecorder r = recorder;
- return r != null && r.isActive();
+ public void setViewport(long version, int x, int y, int w, int h) {
+ Viewport viewport = new Viewport(version, x, y, w, h);
+ pendingViewport = viewport;
+ Controller c = controller;
+ if (c != null) c.setViewport(version, x, y, w, h);
}
// Swap the surface the video pipeline draws to. The audio and control
@@ -154,9 +220,14 @@ public final class Session {
// activity is backgrounded. Pass null to detach; pass a new Surface
// (from a recreated SurfaceView) to resume rendering.
public void swapSurface(Surface s) {
+ Surface previous = surface;
surface = s;
VideoSink vk = videoSink;
if (vk != null) vk.setOutputSurface(s);
+ if (s != null && previous == null) {
+ Controller c = controller;
+ if (c != null) c.resetVideo();
+ }
}
// ---- internals ----
@@ -165,23 +236,75 @@ public final class Session {
// reconnect if the death wasn't a user stop(). Runs on one thread for
// the whole session lifetime.
private void run() {
- while (!stopped) {
+ int drops = 0; // consecutive short-lived connections
+ while (true) {
+ if (stopped) return;
if (!connect()) return; // gave up: onError + onStopped fired
+ long upAt = monotonicMs();
+ CountDownLatch latch;
+ AudioStream as;
+ synchronized (this) {
+ if (stopped) return;
+ latch = endSignal;
+ as = audioStream;
+ }
try {
- endSignal.await(); // park until the pipeline dies or stop()
+ awaitEndOrStall(latch, as);
} catch (InterruptedException ie) {
return;
}
+ long lived = monotonicMs() - upAt;
+
// Decide teardown-and-retry under the monitor so a concurrent
// stop() can't interleave: without this, onReconnecting()
// could fire after stop()'s onStopped(), breaking the
// listener contract.
+ boolean exhausted;
synchronized (this) {
if (stopped) return; // user stop()
- Log.i("session: video stream ended - link lost, reconnecting");
- tearDownInstalled();
- endSignal = new CountDownLatch(1);
- if (listener != null) listener.onReconnecting();
+ if (lived >= HEALTHY_MS) drops = 0;
+ exhausted = drops >= RECONNECT_BACKOFF_MS.length;
+ if (!exhausted) {
+ Log.i("session: link lost after %d ms - reconnecting (%d/%d)",
+ lived, drops + 1, RECONNECT_BACKOFF_MS.length);
+ tearDownInstalled();
+ endSignal = new CountDownLatch(1);
+ if (listener != null) listener.onReconnecting();
+ }
+ }
+ if (exhausted) {
+ Log.e("session: gave up after %d reconnects without a healthy link",
+ RECONNECT_BACKOFF_MS.length);
+ giveUp(new IOException("link kept dropping; gave up after "
+ + RECONNECT_BACKOFF_MS.length + " reconnects"));
+ return;
+ }
+
+ long delay = RECONNECT_BACKOFF_MS[drops++];
+ try { Thread.sleep(delay); }
+ catch (InterruptedException ie) { return; }
+ }
+ }
+
+ // Park until the pipeline dies, stop() fires, or the audio socket has
+ // been silent long enough that the link must be gone. Returning
+ // without the latch firing leaves the readers parked; the caller's
+ // tearDownInstalled() closes their streams, which unblocks them.
+ //
+ // The watchdog only arms once audio is actually flowing. If the
+ // target cannot capture audio the server reports the stream disabled,
+ // AudioStream returns immediately, and there is no reliable idle
+ // signal left - in that case fall back to waiting indefinitely rather
+ // than inventing one from the video socket, which is legitimately
+ // silent whenever the target's screen is static.
+ private void awaitEndOrStall(CountDownLatch latch, AudioStream as)
+ throws InterruptedException {
+ while (!latch.await(STALL_POLL_MS, TimeUnit.MILLISECONDS)) {
+ if (stopped || as == null || !as.isStarted()) continue;
+ long idle = monotonicMs() - as.lastPacketAtMs();
+ if (idle >= STALL_MS) {
+ Log.w("session: no audio for %d ms - link presumed dead", idle);
+ return;
}
}
}
@@ -190,7 +313,7 @@ public final class Session {
// live (read threads started); false if the budget was exhausted, in
// which case onError() + onStopped() have already fired.
private boolean connect() {
- Throwable lastErr = null;
+ Exception lastErr = null;
for (int attempt = 0; attempt < BACKOFF_MS.length && !stopped; attempt++) {
if (BACKOFF_MS[attempt] > 0) {
Log.i("session: retry %d/%d after %d ms",
@@ -204,7 +327,7 @@ public final class Session {
return true; // success; the read threads own the live session
} catch (InterruptedException ie) {
return false;
- } catch (Throwable t) {
+ } catch (Exception t) {
Log.w("session: bring-up attempt %d/%d failed: %s",
attempt + 1, BACKOFF_MS.length, t);
lastErr = t;
@@ -214,12 +337,22 @@ public final class Session {
}
if (stopped) return false;
Log.e(lastErr, "session: gave up after %d attempts", BACKOFF_MS.length);
- Throwable err = lastErr;
+ return giveUp(lastErr);
+ }
+
+ // Terminal failure. Marks the session stopped, tears down, and fires
+ // the final listener pair exactly once. Must not be called while
+ // holding the monitor: the listener runs on the caller's thread.
+ // Always returns false so callers can `return giveUp(err)`.
+ private boolean giveUp(Throwable err) {
synchronized (this) {
if (stopped) return false;
stopped = true;
- tearDownInstalled();
+ stoppedNotified = true;
}
+ adb.abort();
+ tearDownInstalled();
+ endSignal.countDown();
if (listener != null) {
listener.onError(err);
listener.onStopped();
@@ -231,6 +364,41 @@ public final class Session {
// monitor - but only if stop() hasn't already fired, in which case
// we tear down the locals we just built so nothing leaks.
private void bringUp() throws Exception {
+ CountDownLatch finished = new CountDownLatch(1);
+ AtomicBoolean timedOut = new AtomicBoolean();
+ Thread deadline = new Thread(() -> {
+ try {
+ if (!finished.await(BRING_UP_DEADLINE_MS, TimeUnit.MILLISECONDS)) {
+ timedOut.set(true);
+ Log.e("session: bring-up exceeded %d ms", BRING_UP_DEADLINE_MS);
+ adb.abort();
+ }
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ }
+ }, "bring-up-deadline");
+ deadline.setDaemon(true);
+ deadline.start();
+
+ Exception failure = null;
+ try {
+ bringUpAttempt();
+ } catch (Exception e) {
+ failure = e;
+ } finally {
+ finished.countDown();
+ deadline.interrupt();
+ }
+ if (timedOut.get()) {
+ SocketTimeoutException timeout = new SocketTimeoutException(
+ "session bring-up exceeded " + BRING_UP_DEADLINE_MS + " ms");
+ if (failure != null) timeout.initCause(failure);
+ throw timeout;
+ }
+ if (failure != null) throw failure;
+ }
+
+ private void bringUpAttempt() throws Exception {
Log.i("session: connect %s:%d", target.host, target.port);
adb.disconnect();
adb.connect(target.host, target.port);
@@ -243,27 +411,32 @@ public final class Session {
AudioStream as = null;
VideoSink vk = null;
VideoStream vs = null;
- MuxRecorder rec = null;
boolean installed = false;
try {
srv = new Server(ctx, adb);
Server.Streams s = srv.bringUp();
- cs = new ControlStream(s.controlIn, s.controlOut, this::onVideoEnded);
+ // Bind the end callbacks to THIS generation's latch, not to
+ // the mutable field. Teardown joins the readers with a 1 s
+ // timeout, so on a black-holed link a reader can outlive its
+ // generation; reading the field at fire time would let it
+ // count down the next generation's latch and fake an
+ // immediate drop on a connection that was fine.
+ final CountDownLatch mine = endSignal;
+ Runnable ended = mine::countDown;
+
+ cs = new ControlStream(s.controlIn, s.controlOut, ended);
ctrl = new Controller(ctx, cs::send);
cs.setInboundSink(ctrl);
- ak = new AudioSink(this::onVideoEnded);
- as = new AudioStream(s.audioIn, ak);
+ ak = new AudioSink(ended);
+ as = new AudioStream(s.audioIn, ak, ended);
- vk = new VideoSink(surface, this::onVideoEnded);
+ vk = new VideoSink(surface, ended);
Controller ctrlRef = ctrl;
- AtomicBoolean reported = new AtomicBoolean();
vs = new VideoStream(s.videoIn, vk,
- (w, h) -> reportConnected(ctrlRef, reported, w, h));
- vs.setOnEnd(this::onVideoEnded);
- rec = new MuxRecorder();
- vs.setRecorder(rec);
+ (w, h) -> reportConnected(ctrlRef, w, h));
+ vs.setOnEnd(ended);
// Start locals before publishing them. stop() either tears down
// a previously installed generation or marks this generation for
@@ -281,35 +454,35 @@ public final class Session {
audioStream = as;
videoSink = vk;
videoStream = vs;
- recorder = rec;
- if (pendingViewW > 0) ctrl.setViewSize(pendingViewW, pendingViewH);
+ Viewport viewport = pendingViewport;
+ if (viewport != null && viewport.w > 0 && viewport.h > 0) {
+ ctrl.setViewport(viewport.version, viewport.x, viewport.y,
+ viewport.w, viewport.h);
+ }
installed = true;
}
} finally {
- if (!installed) tearDownLocals(srv, cs, ctrl, ak, as, vk, vs, rec);
+ if (!installed) tearDownLocals(srv, cs, ctrl, ak, as, vk, vs);
}
}
- private synchronized void reportConnected(Controller ctrl, AtomicBoolean reported,
- int w, int h) {
+ // Fires for every session-meta packet, not just the first. The server
+ // sends a fresh one whenever the target rotates or resizes, and the
+ // listener needs it every time: it carries the geometry the view is
+ // sized to and the touch viewport is derived from. Reporting only the
+ // first left a rotated target drawn into a view shaped for its old
+ // orientation, with touches mapped through the stale rectangle.
+ // Repeats are harmless - the listener's handling is idempotent.
+ private synchronized void reportConnected(Controller ctrl, int w, int h) {
if (stopped) return;
- ctrl.setTargetSize(w, h);
- if (reported.compareAndSet(false, true) && listener != null) {
- listener.onConnected(w, h);
- }
- }
-
- // Fired on the video-reader thread when its read loop exits (EOF,
- // error, or stop()). The video socket is the authoritative stream;
- // its end wakes the supervisor, which either unwinds (stop) or
- // reconnects. A no-op countdown after stop() is harmless.
- private void onVideoEnded() {
- endSignal.countDown();
+ long version = ++geometryVersion;
+ ctrl.setTargetSize(version, w, h);
+ if (listener != null) listener.onConnected(version, w, h);
}
private synchronized void tearDownInstalled() {
tearDownLocals(server, controlStream, controller,
- audioSink, audioStream, videoSink, videoStream, recorder);
+ audioSink, audioStream, videoSink, videoStream);
server = null;
controlStream = null;
controller = null;
@@ -317,14 +490,16 @@ public final class Session {
audioStream = null;
videoSink = null;
videoStream = null;
- recorder = null;
- try { adb.disconnect(); } catch (Exception ignored) {}
+ try {
+ adb.disconnect();
+ } catch (IOException e) {
+ Log.w("session: adb disconnect failed: %s", e);
+ }
}
private static void tearDownLocals(Server srv, ControlStream cs, Controller ctrl,
AudioSink ak, AudioStream as,
- VideoSink vk, VideoStream vs, MuxRecorder rec) {
- if (rec != null) rec.close();
+ VideoSink vk, VideoStream vs) {
// Closing the owning ADB streams first unblocks readers. Join them
// before releasing their sinks so no callback can recreate resources
// after teardown.
@@ -336,4 +511,8 @@ public final class Session {
if (ak != null) ak.release();
if (ctrl != null) ctrl.release();
}
+
+ private static long monotonicMs() {
+ return TimeUnit.NANOSECONDS.toMillis(System.nanoTime());
+ }
}