detachAndReport
Detaches the device. Returns the quarantine detail when the device could not be left clean (the pool keeps it out of circulation), else null. Single-flight: concurrent and duplicate callers share one Detach RPC (120 s gRPC deadline, mapped) and one shared completion, including the quarantine detail. Admitted operations finish before the RPC under an explicit total drain bound (DeviceBounds.drainMs); operations starting after detach began are rejected locally. A detach invoked from the same admitted operation (for example an awaitUntil condition calling detach) fails immediately with TapUsageException instead of waiting for itself. Runs under a bounded non-cancellable context so teardown completes even when the caller is cancelled.
Drain-timeout contract (fail closed, this device only): when admitted operations are still in flight past the per-instance drain bound, the first caller throws DEADLINE_EXCEEDED immediately. The handle is already poisoned (detach started, so every new operation is rejected locally) and exactly one fail-closed job is launched on the explicitly owned per-Device cleanupScope (never GlobalScope). That job sends one best-effort, bounded Detach for this device — the server tears the session down even with the stuck command in flight — then unregisters the handle and publishes the shared terminal failure. The owner TapConnection and every other device attached through it are left untouched: one wedged command must not tear down unrelated devices (in JUnit, the connection is shared by every later test). A Detach failure or quarantine detail is suppressed into that failure (observable via suppressed) without stranding the registry or the scope. Subsequent callers await the same shared completion and receive the same terminal failure instance. The normal path (drain succeeds) issues one bounded Detach and returns its quarantine result.