Skip to content

Recover capture after sleep, lost displays and repeated access loss - #205

Merged
whoisaldo merged 14 commits into
release/0.3.5-candidatefrom
fix/capture-recovery
Nov 8, 2026
Merged

whoisaldo merged 14 commits into
release/0.3.5-candidatefrom
fix/capture-recovery

Conversation

@whoisaldo

@whoisaldo whoisaldo commented Nov 7, 2026 •

Copy link
Copy Markdown
Member

What and why

Windows capture recovery, from the field logs: round 1 rows 2, 9 and 14, and round 2 row 1. Reviews r1 to r3 are folded in as additive commits.

No display (NoDisplay, 12 events on 8 installs in a day)

When enumeration listed no output, capture opened the detection adapter's first output by the ordinal it had at startup. That ordinal goes stale after a dock, lid, sleep or driver change, the call never captured anything, and the error hid what was wrong.

  • Capture opens the adapter and output objects from the enumeration that listed them, and reports NoDisplay ("no display is attached to the desktop") when none is attached.
  • While no display is on, the retry wait looks for one every 250 ms. It used to sleep up to 5 s.
  • The iPad says "Your PC has no active display. Turn on a screen to continue." over the held picture. This uses a new heartbeat notice, NOTICE_NO_DISPLAY = 2, which older iPads ignore. The string is English only and awaits translation.

A display that moved

Outputs carry their adapter's LUID. After a lost duplication, capture first asks the duplicated output for its own description. It lists all outputs only in three cases: the output left the desktop or was renamed, capture runs on the primary for a missing display, or re-creating the duplication failed.

  • Both calls run under a new stall limit, list_outputs (1 s warning, 4 s limit), and keep the picture alive while they wait.
  • When the target now resolves to another output or adapter, the loop ends with Reopen and the stage reopens the display at once. A second Reopen within 1 s waits at the 500 ms blocked pace.
  • The target lookup doesn't log, so a missing chosen display no longer warns at every loss.

Sleep (13 "capture loop silent" restarts on 11 installs; 7 of 10 were PCs waking after 184 s to 12,886 s)

The watchdog compared heartbeats with a clock that counts the time asleep.

  • The supervisor looks every 250 ms. A gap of more than 2 s between two looks restamps the heartbeats and gives the stages 10 s of grace while displays wake.
  • A capture loop still silent when the grace ends is restarted ("capture loop silent for 10000ms"). A wedge once capture runs again is caught 3 s later, as before.

Access lost (297 events on 51 installs, and one 0.3.4 PC that lost it every 1 to 4 s for an hour)

  • It is logged at info. A loss not back within a second warns once.
  • On that PC each loss waited the full 2 s ladder step. "Desktop duplication recovered" took a median of 2,002 ms, every 2.8 s, and the iPad got 3 to 17 fps.
    • A duplication that worked for 250 ms is now re-created at once, so quick repeated losses cost tens of milliseconds.
    • Re-creations stay under four a second.
  • ReleaseFrame and the staging Map returned ACCESS_LOST with ?, which ended the loop and restarted the pipeline (seven times on that PC). They now go to the same handler as a lost acquire. The stage also waits out any ACCESS_LOST that still leaves the loop.

Second screen after a lost duplication

  • Static screen: it resends its cached picture after the duplication is re-created at the same size. Before, the watchdog restarted the pipeline 5 s later.
  • Hold limit: the cached picture is resent for at most 15 s. Then the stage reopens the display without it, so a virtual display gone dark still reaches the watchdog.
  • Proof binding: a proven second screen stays proven when the stage reopens the same output in the same generation, so the watchdog judges it. Every listing of the outputs goes through capture::listing, which clears the proof when the listing doesn't show that output as it was. The DXGI enumerator and the debug-build fake display take a token only that module can make, so the compiler keeps listings there. No public listing skips the judge: the startup banner gets only its display label. A test holds the raw Windows enumeration and removed names to known files, aliases included. It must prove itself again in four cases:
    • a new generation;
    • a display attached anew;
    • another adapter or name;
    • a detach seen in between.
  • First proof: a reopen during the first proof is noted as an interruption, not a capture error.

Blocked retries

The retry pace never started over within an app run, so a PC locked for the fourth time waited up to 5 s after it was unlocked. The pace restarts once capture has worked again.

How it was tested

  • Natively on Windows 11 (RTX 5080), plus local test isolation:
    • On 2157f65 (review r6): the workspace build passed, clippy -D warnings and fmt are clean, and the capture:: lib tests passed (162). macOS: the host lib passed (1,108), and clippy is clean.
    • On 5163125 (review r5): the host lib passed (1,106), the second-screen end-to-end tests passed (4), and clippy and fmt are clean. macOS: the host lib passed (1,107), and clippy is clean.
    • On 36d2e91: cargo test -p eternal-host -p eternal-wire --locked passed all 32 suites (1,067 host lib tests). cargo clippy --workspace --all-targets --locked -- -D warnings and cargo fmt --all -- --check are clean.
    • On the merges with the candidate:
      • cced05b: the host lib (1,103), clippy and fmt pass.
      • 2cc4ae3: the supervisor, transport and capture lib tests plus hello_standby, listen_on and amf_encoder pass.
      • amf_encoder and one transport summary test each failed once in a run, then passed on reruns, and on the candidate alone.
  • Every new behaviour test fails on a build that puts the old behaviour back behind the new APIs, one variant per review round:
    • Round 1, 9 tests: sleep gap, 250 ms ladder, info/warn split, NoDisplay notice, display poll, target move, pace reset, stage backstop, cached picture.
    • Review r1, 4 tests: wake grace, silent lookup, immediate reopen, picture hold.
    • Review r2, 4 tests: proof kept across a reopen, reopen keeps the first proof, and both paused-clock timing tests.
    • Review r4, 1 test: a listing without the output clears the proof. It fails alone on a build where the listing keeps the proof.
    • Review r5, 1 test: a reopen listing with no outputs at all, then the same display back, re-proves; so do the other entry points. With the judge not clearing the proof, it and the r4 test fail; the same-output test and the listing scan pass. The scan fails when an unjudged listing is added (checked by hand).
    • Review r6: fixtures show the scan catching an alias, a grouped import and a removed name, and ignoring comments, strings and longer names. A token forged outside listing fails to compile (E0603, checked by hand).
    • Review r3, 4 tests:
      • With the output identity ignored, the adapter-change and detach regressions fail.
      • With the proof never kept, both keeps-its-proof tests fail.
    • In every variant, the control tests pass.
  • iPad on Xcode 16.4 and the iOS 18.6 simulator, on 2cc4ae3 (with the translations from Translate the out-of-date PC card and the locked-PC notice #213):
    • StreamRecoveryTests, PCNoticeTests, LocalizationTests and WireProtocolGoldenTests: 54 passed. Only the no-display pill awaits translation.
    • DisplayStateTests: 3 passed, including the new no-display pill screenshot test.
  • SETUP.md: no change. It doesn't describe the locked-PC or no-display messages.
  • No pairing tokens, codes, QR codes or private screenshots in the diff.

Native reproductions (Windows 11, RTX 5080, isolated test copies)

Sleep. A headless host on synthetic capture was suspended for 30 s. Its capture thread was released 6 s after the rest, as when capture is still inside a display call after waking.

  • Before (candidate): Pipeline stage failed — automatic restart scheduled stage=Capture reason="encoder silent for 3048ms while capture advances". An earlier base run released capture 2 s after the rest and logged "capture loop silent for 30056ms".
  • After: The host did not run for a while (the PC slept?) — restarting the pipeline watchdog's clock gap_ms=30305, and no restart.

No display. Real capture in a session with no desktop attached:

  • Before: the stale-ordinal fallback's output failed duplication, and the block was reported as TooManyCaptureApps (0x887A0022).
  • After: No display is attached to the desktop … adapters=3, then block=NoDisplay detail=no display is attached to the desktop (3 graphics adapters listed).

Topology, refresh and virtual display changes. These ran on real displays: two monitors, with a refresh-rate change on the second one and that monitor switched off and back. They ran under the display and measure locks. The layout, vdd_settings.xml and the VDD state were restored and checked.

  • Measured: repeated access loss. Each refresh change on the second monitor took the duplication away. The test hosts captured the primary monitor; see below.

    • Before (candidate): every loss logged WARN Desktop duplication access lost, and the recoveries climbed the ladder at took_ms=2, 101, 251, 501, 1001, 2001. That is the field PC's 2 s step.
    • After: every loss logged at INFO, and every recovery took took_ms=0 to 7. Switching the second monitor off and back cost 3 ms before and 37 ms after.
  • Not measured on real displays:

    • A moved target: the captured display going away, coming back, or changing adapter.
    • The list_outputs timings.
    • The virtual-display loss case.

    The test settings file lacked fields the host requires (start_on_boot), so each host captured the primary monitor instead of the second one or the virtual display. The fix's cheap output check found the primary still attached, so no listing ran. Without a virtual display, Second screen never started.

    These paths are covered by the unit tests above (target move, silent lookup, immediate reopen, picture hold, proof binding, listing), not by a native run.

The watchdog compares stage heartbeats with the host clock, and that
clock counts the time a PC is asleep. At the supervisor's first look
after waking, before capture has run again, every heartbeat looked as
old as the sleep, and the pipeline restarted under the viewer. 7 of the
10 "capture loop silent" values in a day of field logs were 184 s to
12,886 s: PCs waking up.

The supervisor looks every 250 ms. A gap of more than 2 s between two
looks means nothing in the process ran in between, so the watchdog
restamps the heartbeats it watches and gives the stages a fresh
window. A real wedge still shows 3 s later. A generation start does not
count as a gap: joining the old one can take seconds.
No display (NoDisplay, 12 events on 8 installs in a day): when
enumeration listed no output, capture opened the detection adapter's
first output by the ordinal it had at startup. That ordinal goes stale
after a dock, lid, sleep or driver change; the call never captured
anything, and its error ("there is no adapter with the specified
ordinal") hid what was wrong. Capture now opens the adapter and output
objects from the enumeration that listed them, and reports "no display
is attached to the desktop" when none is. While no display is on, the
retry wait looks for one every 250 ms instead of sleeping up to 5 s,
and the iPad shows "Your PC has no active display. Turn on a screen to
continue." through the heartbeat notice (new NOTICE_NO_DISPLAY = 2;
English only, awaiting translation). Older iPads ignore the value.

A lost duplication on a display that moved: outputs now carry their
adapter's LUID. After ACCESS_LOST, when the target resolves to another
output or adapter than the one being duplicated (a display unplugged or
back, a driver restart, a dock), the stage opens the target afresh
instead of re-creating the duplication on the old output. Before, a
display that came back stayed replaced by the primary.

Access lost (297 events on 51 installs): it is routine with the lock
screen, UAC prompts and mode changes, so it is logged at info, and a
loss that is not back within a second warns once. On a 0.3.4 PC whose
desktop was taken again every 1 to 4 s for about an hour, the retry
ladder was held for 5 s, so every loss waited the full 2 s step and the
iPad got 3 to 17 fps. A duplication that worked for 250 ms is now
re-created at once, so quick repeated losses cost tens of milliseconds,
and re-creations stay under four a second.

The same PC's capture loop also exited seven times with ACCESS_LOST
(0x887A0026), each a pipeline restart. Only AcquireNextFrame's
ACCESS_LOST reached the handler; ReleaseFrame and the staging Map
returned theirs with `?` and ended the loop. They now go to the same
handler as a lost acquire, and the stage waits out any ACCESS_LOST that
still leaves the loop as a display change instead of failing.

A static second screen also keeps sending its cached picture after the
duplication is re-created at the same size: keepalives waited for a new
image that an unchanged screen never brings, and the watchdog restarted
the pipeline 5 s later.

Blocked retries: the retry pace never started over within an app run,
so a PC locked for the fourth time waited up to 5 s after it was
unlocked. The pace restarts once capture has worked again.
@whoisaldo whoisaldo added the 0.3.5 After 0.3.4 label Nov 7, 2026
After a gap in the watchdog's looks (a PC that slept), the heartbeats
were restamped and a capture loop still silent 3 s later was restarted.
Waking displays and drivers can hold capture inside a call for longer
than that while nothing is wrong. The stages now get 10 s after such a
gap; a capture loop still silent when it ends is restarted then, and a
wedge once capture runs again is caught 3 s later, as before.
…ld picture

Review r1 of #205.

After ACCESS_LOST the handler listed every adapter and output from a new
DXGI factory, with no time limit, at every loss: up to four times a
second during mode changes and wake-ups. It now asks the duplicated
output for its own description first, and lists the outputs only when
that output left the desktop or was renamed, while capture runs on the
primary for a missing display, or after a re-creation failed. Both calls
run under a new stall limit, list_outputs (1 s warning, 4 s limit, as
for device creation), and keep the picture alive while they wait. A call
that does not return in time counts as no move. Each listing logs how
long it took.

That check looked the target up through resolve_target, which warns that
the chosen display is missing: one warning per loss while it was. It has
its own lookup now, which logs nothing.

A moved display was reopened through the blocked pace, 500 ms later. The
loop now ends with Reopen, and the stage opens the display again at
once; a second Reopen within a second waits at the blocked pace, so a
layout that keeps changing cannot reopen it in a loop.

Resending the cached picture after a re-created duplication hid a
virtual display that sends no frames any more: the picture would stay
frozen for good. A second screen now resends it for at most 15 s from
the first re-creation without a new image. Then the stage reopens it
without the cached picture, and the watchdog restarts the pipeline 5 s
later if it is still dark, as before the cached picture was kept. The
mirror keeps resending.

The no-display wait test allows 4 s instead of 2.5 s on a loaded test
machine; the wait it checks is 5 s.
…x/capture-recovery

# Conflicts:
#	host/src/supervisor.rs
…paused clocks

Review r2 of #205.

A second screen reopened by the stage (a display that moved, or one that
showed no new image for 15 s after its duplication came back) was
reconciled again, and reconciling started a new proof. While it proved
itself the watchdog skipped its missing-frame check, and 5 s later the
proof failed back to the mirror, so the restart meant for a display gone
dark never came. The generation whose second screen captured its first
image is now remembered: reconciling again in that generation does not
prove it again. A new generation, or a display attached anew, proves
itself as before.

A reopen during the first proof was noted as a capture error, which
fails the proof: a display that moved while the second screen started
went back to the mirror. The stage now notes a reopen as an interruption
only; the proof keeps its deadline.

The no-display wait and the stage's reopen spacing take their clock (and
the wait its sleep) as arguments. Their tests run on paused clocks: the
display comes back between two looks and is seen at the next one, at
750 ms; a reopen 999 ms after the last waits at the blocked pace, and
reopens 1,000 ms or more apart do not.
Review r3 of #205.

A proven second screen stayed proven for its whole capture generation,
whatever display it was. A virtual display back under the same name on
another adapter made capture reopen it; the reconciliation matched it by
name and skipped its proof, so a dark replacement would have cost a
watchdog restart.

The proof now records the output it ran on: the adapter's LUID and the
device name. Reconciling the same output again in the same generation
keeps it, as a recovery reopen should. Another adapter or name proves
itself again, and so does a display that was seen off the desktop in
between, by the reconciliation or by capture's check of the duplicated
output, even when it comes back under the same name without a new
attachment.
…x/capture-recovery

# Conflicts:
#	ios/EternalMonitor/Resources/Localizable.xcstrings
#	ios/EternalMonitorTests/LocalizationTests.swift
Review r4 of #205.

When the duplicated output still described itself as attached but a
re-creation failed, capture listed the outputs and could find the
virtual display gone, yet kept its proof. The same display back as the
same output before the next reconciliation then inherited it, and a dark
one would have cost a watchdog restart.

The listing is now judged in one place: a listing without the output as
it was (absent, or its name under another adapter) clears the proof, and
only a listing that still shows the same output keeps it.
…x/capture-recovery

# Conflicts:
#	host/src/capture/second_screen.rs
…x/capture-recovery

# Conflicts:
#	ios/EternalMonitorTests/LocalizationTests.swift
Review r5 of #205.

The reopen's first listing could find no output at all and end capture,
and nothing cleared the second screen's proof: the same display back
before the next reconciliation inherited it, as after r3 and r4's
listings. Each listing path had to remember to judge what it saw.

A new capture::listing module is now the only way to list the outputs,
and it judges every listing: one that does not show the proven output
as it was (absent, or its name under another adapter) clears the proof.
The DXGI enumerator takes a token only that module can make, so a new
listing path cannot skip it. The capture loop's listing (which a reopen
makes first), the check after a lost duplication, the no-display wait,
reconciliation, the virtual display worker and its mode settling, and
the Settings page's display list all go through it. Only the startup
banner, before any host state exists, and a native test that prints
the outputs list without one. A test holds every remaining way of
seeing outputs to those places.
Review r6 of #205.

The startup banner's listing was a public function that made its own
token, and the backstop scan matched calls, so a `use … as` alias of it
listed outputs unseen. Visibility can't be narrowed to the banner: it is
in the binary crate, and `pub(in …)` only names ancestor modules. So the
unjudged listing is gone: the banner gets only its display label from
`startup_display_label`, and the native test that prints the outputs
lists them through the judge with its own host state. The debug-build
fake display's outputs now take the same token as the DXGI enumerator,
so the compiler keeps both to `capture::listing`.

The scan matches identifiers outside comments and string literals, so
aliases are seen, and has fixtures for an alias, a grouped import, a
removed name, a comment, a string and a longer name.

`observe_listing` names `OutputInfo` by path: the module imports it only
for Windows, test and debug builds, and a macOS release build failed.
@whoisaldo
whoisaldo marked this pull request as ready for review November 8, 2026 08:18
@whoisaldo
whoisaldo merged commit 241fa1b into release/0.3.5-candidate Nov 8, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

0.3.5 After 0.3.4

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants