---
phase: 01-unify-duplicated-discovery-loops
plan: 03
type: execute
wave: 2
depends_on:
  - 01-01
files_modified:
  - src/lifx/network/mdns/discovery.py
autonomous: true
requirements:
  - D-07
  - D-08
user_setup: []

must_haves:
  truths:
    - "discover_lifx_services uses IdleDeadline for its timeout arithmetic, retiring the third copy of the inline dual-deadline logic (D-07)"
    - "The mDNS loop's bare `except Exception` around transport.receive() is tightened: LifxTimeoutError stops collection cleanly via break (D-08)"
    - "A genuine LifxNetworkError during receive() no longer masquerades as 'no responses': it is logged distinctly at WARNING and the loop breaks (D-08, researcher recommendation — callers do not handle LifxNetworkError today)"
    - "Truly unexpected exceptions are logged at ERROR with exc_info=True and propagate (D-08)"
    - "The distinct idle-timeout vs overall-timeout DEBUG log messages are preserved (D-07)"
    - "Per-serial dedup and yielded LifxServiceRecord behaviour of discover_lifx_services is unchanged"
  artifacts:
    - path: "src/lifx/network/mdns/discovery.py"
      provides: "discover_lifx_services adopting IdleDeadline with tightened exception routing"
      contains: "IdleDeadline"
  key_links:
    - from: "src/lifx/network/mdns/discovery.py discover_lifx_services"
      to: "lifx.network.utils.IdleDeadline"
      via: "import + instantiation"
      pattern: "IdleDeadline\\("
    - from: "src/lifx/network/mdns/discovery.py discover_lifx_services"
      to: "lifx.exceptions.LifxTimeoutError / LifxNetworkError"
      via: "tightened except clauses around transport.receive()"
      pattern: "except LifxTimeoutError"
---

<objective>
Make the mDNS `discover_lifx_services` loop in `src/lifx/network/mdns/discovery.py` adopt the
shared `IdleDeadline` helper (Plan 01), killing the third copy of the idle/overall-timeout
arithmetic (D-07), and tighten its bare `except Exception` around `transport.receive()` to match
the LIFX loops' typed exception handling (D-08): `LifxTimeoutError` breaks cleanly,
`LifxNetworkError` is logged at WARNING and breaks (callers do not handle it today), and unexpected
errors are logged at ERROR with `exc_info=True` and re-raised.

Purpose: Eliminate the last duplicate of the deadline arithmetic and stop genuine socket errors
from silently masquerading as "no devices found" (Pitfall 5).

Output: Refactored `discover_lifx_services` — same yield contract and dedup behaviour, shared
deadline helper, typed exception routing.
</objective>

<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/01-unify-duplicated-discovery-loops/01-CONTEXT.md
@.planning/phases/01-unify-duplicated-discovery-loops/01-RESEARCH.md
@.planning/phases/01-unify-duplicated-discovery-loops/01-PATTERNS.md
@.planning/phases/01-unify-duplicated-discovery-loops/01-01-SUMMARY.md
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Adopt IdleDeadline and tighten exception handling in discover_lifx_services</name>
  <files>src/lifx/network/mdns/discovery.py</files>
  <read_first>
    - src/lifx/network/mdns/discovery.py (full file; discover_lifx_services at 175–355; the timeout arithmetic at 224–262; the bare `except Exception` around receive() at 264–277; the import block at 21–47)
    - src/lifx/network/utils.py (the IdleDeadline class from Plan 01 — remaining()/mark_response()/idle_expired/overall_expired contract)
    - src/lifx/network/mdns/transport.py (lines 200–232 — confirms MdnsTransport.receive() raises LifxTimeoutError for timeouts and LifxNetworkError for socket errors)
    - src/lifx/exceptions.py (LifxTimeoutError, LifxNetworkError class definitions and inheritance)
    - .planning/phases/01-unify-duplicated-discovery-loops/01-PATTERNS.md (mdns/discovery.py section lines 201–276 — current loop and the tightened exception pattern to apply)
    - .planning/phases/01-unify-duplicated-discovery-loops/01-RESEARCH.md (Pitfall 5 lines 409–429; Open Question 2 lines 718–723 — break-with-WARNING recommendation)
  </read_first>
  <behavior>
    - discover_lifx_services replaces inline idle/overall timeout arithmetic with an IdleDeadline instance and still emits the two distinct DEBUG logs (action "idle_timeout" / "overall_timeout").
    - When transport.receive() raises LifxTimeoutError, the loop breaks (clean end of collection) — equivalent to today's "no responses" termination.
    - When transport.receive() raises LifxNetworkError, a WARNING dict-style log is emitted (class "discover_lifx_services", action "network_error", error str(e)) and the loop breaks; the error no longer looks like "no responses".
    - When transport.receive() raises any other Exception, an ERROR dict-style log with exc_info=True is emitted and the exception propagates (raise).
    - Per-serial dedup (seen_serials) and yielded LifxServiceRecord behaviour are unchanged.
  </behavior>
  <action>
    Refactor only `discover_lifx_services`.

    1. Add imports: `from lifx.network.utils import IdleDeadline` and
       `from lifx.exceptions import LifxNetworkError, LifxTimeoutError` (place alongside the
       existing import block at lines 21–47; both exception types already exist in
       `lifx.exceptions`).
    2. Replace the `idle_timeout = max_response_time * idle_timeout_multiplier` /
       `last_response_time = request_time` setup and the dual-condition loop header (lines 224–262)
       with an `IdleDeadline(timeout, idle_timeout)` instance where
       `idle_timeout = max_response_time * idle_timeout_multiplier`. Inside the loop, check
       `deadline.idle_expired` / `deadline.overall_expired` to emit the existing distinct DEBUG logs
       (keep `class:"discover_lifx_services"`, `method:"discover"`, `action:"idle_timeout"` and
       `action:"overall_timeout"` with their fields), then `remaining = deadline.remaining()` and
       break if `remaining <= 0` before `transport.receive(timeout=remaining)`.
    3. Replace the bare `except Exception:` block (lines 264–277) around `transport.receive()` with
       three typed clauses (D-08): `except LifxTimeoutError: break`;
       `except LifxNetworkError as e:` → WARNING dict-style log
       (`class:"discover_lifx_services"`, `action:"network_error"`, `error: str(e)`) then `break`;
       `except Exception as e:` → ERROR dict-style log
       (`class:"discover_lifx_services"`, `action:"unexpected_error"`, `error: str(e)`,
       `exc_info=True`) then `raise`. Order matters: LifxTimeoutError before LifxNetworkError (both
       subclass LifxError), generic Exception last.
    4. Call `deadline.mark_response()` where the loop currently sets
       `last_response_time = response_timestamp` (line 333), i.e. after a record is successfully
       yielded, preserving existing idle-reset semantics for the mDNS loop. Do NOT change the inner
       parse-error `except Exception` block (lines 335–345) — that one wraps DNS parsing, not the
       receive call, and is out of D-08's scope.

    Use `time.monotonic()` for any residual timing; keep the `start_time` completion-log field.
    Preserve the `AsyncGenerator[LifxServiceRecord, None]` return annotation and Australian-English
    spelling in any touched docstrings/comments.
  </action>
  <verify>
    <automated>uv run pyright src/lifx/network/mdns/discovery.py && uv run ruff check src/lifx/network/mdns/discovery.py</automated>
  </verify>
  <acceptance_criteria>
    - `grep -c "IdleDeadline" src/lifx/network/mdns/discovery.py` returns >= 1.
    - `grep -c "except LifxTimeoutError" src/lifx/network/mdns/discovery.py` returns >= 1 and `grep -c "except LifxNetworkError" src/lifx/network/mdns/discovery.py` returns >= 1.
    - The `network_error` branch uses `_LOGGER.warning(` and the `unexpected_error` branch uses `_LOGGER.error(` with `exc_info=True` and a following `raise`.
    - `grep -c "last_response_time" src/lifx/network/mdns/discovery.py` returns 0 (inline deadline tracking replaced by IdleDeadline).
    - `uv run pyright src/lifx/network/mdns/discovery.py` reports 0 errors.
    - `uv run --frozen pytest tests/test_network/test_mdns/ -q` passes (existing mDNS discovery coverage).
  </acceptance_criteria>
  <done>discover_lifx_services uses IdleDeadline and routes receive() exceptions by type (timeout→break, network→WARNING+break, other→ERROR+raise); distinct timeout logs preserved; mDNS tests pass; Pyright clean.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| external network → discover_lifx_services | Untrusted multicast mDNS responses from arbitrary hosts cross into the discovery generator |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-01-06 | Denial of Service / Repudiation | Genuine socket error silently masquerading as "no responses", hiding network failure from callers | mitigate | Tightened exception routing (D-08): LifxNetworkError logged at WARNING then break; unexpected errors logged at ERROR with exc_info=True then re-raised. A real failure is now diagnosable, not swallowed. |
| T-01-07 | Denial of Service | mDNS response flood / idle starvation | accept | IdleDeadline enforces the same overall+idle bounds as before; mark_response() preserves existing idle-reset semantics. mDNS dedup (seen_serials) unchanged. No new attack surface introduced. |

ASVS L1: No HIGH-severity items. mDNS parsing/validation (DNS adversarial coverage) is unchanged
and out of this plan's scope. No package installs (Package Legitimacy Gate N/A — stdlib only).
</threat_model>

<verification>
- `uv run pyright` reports 0 errors (strict).
- `uv run ruff check .` reports no errors.
- `uv run --frozen pytest tests/test_network/test_mdns/ -q` passes.
</verification>

<success_criteria>
- discover_lifx_services uses IdleDeadline; the third copy of the deadline arithmetic is gone (D-07).
- receive() exceptions routed by type per D-08 (timeout→break, network→WARNING+break, other→ERROR+raise).
- Distinct idle/overall timeout DEBUG logs preserved; dedup + yield contract unchanged.
</success_criteria>

<artifacts_produced>
## Artifacts this phase produces (Plan 03)

- Modified `src/lifx/network/mdns/discovery.py`:
  - `discover_lifx_services` now uses `IdleDeadline` for timeout arithmetic (D-07) and routes `transport.receive()` exceptions by type (D-08).
  - New imports: `IdleDeadline` from `lifx.network.utils`; `LifxNetworkError`, `LifxTimeoutError` from `lifx.exceptions`.
  - Removed inline state: `last_response_time` / inline `idle_timeout` arithmetic.
- No public API surface symbols added or renamed; `discover_lifx_services` signature preserved.
</artifacts_produced>

<output>
Create `.planning/phases/01-unify-duplicated-discovery-loops/01-03-SUMMARY.md` when done.
</output>
