# DF-5422 VERDICT — ucom/uftdi TX close race (`ttywai` hang)

**Status: reproduced (live device traffic on the QEMU usb-serial rig bound
to uftdi; 10+ hangs across runs). Impact: dos (local; hang of the closing
process + TX data loss + port pollution until replug; deterministic with an
evil/dead device). Confidence: certain.**

## Rig

QEMU 7.2 `-device usb-serial` (presents FTDI 0403:6001 FT232BM, including
the 2-byte FTDI IN status headers and the FTDI vendor control requests) on
a piix3-usb-uhci controller; host-side chardev socket daemon injects RX and
counts TX. Guest: DragonFly 6.5-DEVELOPMENT X86_64_GENERIC (env.txt).
`uftdi0` attaches, `/dev/cuaU0` (0660 uucp:dialer) appears.

## Reproduction

* `run.log` — `timeout 10 /root/uftdi_live w 1024`: **10.05 s real, exit
  124** — close(2) never returned; timeout(1) had to kill the process.
  Earlier live captures of the same state show `ps` STAT `I2/S*` WCHAN
  **`ttywai`** — the `ttysleep(..., "ttywai", tp->t_timeout)` at
  sys/kern/tty.c:1396-1398 inside `ttywait()`.
* `run.2.log` — size sweep: n=64/65/128/1024 **all** hit the 6 s timeout
  (rc=124) in the later (warmer) port state; in the fresh-boot state the
  boundary was >64 bytes (6/6 hangs at 65..1024, one-buffer 64 usually
  completing) — consistent with a close-vs-drain *race* whose window
  shifts with taskqueue/USB timing, not with a fixed size check.
* Host-side TX counter (chardev socket): during a 1024-byte hang exactly
  **+64 bytes** arrived — one UFTDI_OBUFSIZE transfer (uftdi.c:82,403).
  The remaining 960 bytes never reach the wire.
* QEMU packet tracing (earlier boot, `-trace usb_packet_state_change`):
  trace line count **frozen** across the hang window (118553 → 118553 over
  6 s) — the bulk-OUT chain is fully idle while the closer sleeps; nothing
  is draining, nothing can wake `TSA_OCOMPLETE`.
* Controls: `uftdi_probe2 1024 0 1 0 1` (write, 2 s settle, TIOCOUTQ
  polls, CLOCAL) — `outq` drains to **0**, `closed in 0 ms`; 200-byte
  blocking variant also drained (`nopoll outq=0`, closed 0 ms). RX path
  verified good: 10 host-injected bytes delivered intact (`414243...`,
  2-byte status header stripped per packet by uftdi_read_callback,
  uftdi.c:442-476). Baseline open/param/close on the good device:
  open ≈ 0 ms, tcsetattr ≈ 0 ms, close ≈ 330-390 ms (the synchronous
  cfg-chain wait — see DF-5424).

## Why it hangs (path:line)

1. Writer: `write(2)` queues N>64 bytes into `tp->t_outq` (ttwrite,
   tty.c:1988+); the WR xfer drains 64 bytes per completion
   (uftdi_write_callback → ucom_get_data, uftdi.c:390-424 /
   usb_serial.c:1844-1942) — but only once the open/param cfg tasks have
   set `UCOM_FLAG_GP_DATA` (usb_serial.c:727-728), ~350 ms after open.
2. `close(2)` → `ucom_dev_close` → `ucom_close` (usb_serial.c:938):
   - 955-957: queues `cfg_close` and `usb_proc_mwait`s for the *message*
     only — not for the outq drain; the async drain is still running.
   - 959: clears `UCOM_FLAG_HL_READY` → `ucom_start()` no-ops
     (usb_serial.c:1734-1739) → `t_oproc` is dead for `ttywait`.
   - 966: `l_close` → `ttylclose` (tty.c:1562) → `ttywflush` →
     `ttywait` (tty.c:1384-1406): sleeps on `TSA_OCOMPLETE` with
     `tp->t_timeout == 0` (no timeout).
3. Nobody ever calls `ttwwakeup()` on TX completion (ucom_get_data lacks
   it; ucom_start is dead), and the USB WR chain has gone idle (QEMU trace
   frozen) — the sleep is permanent. PCATCH makes SIGTERM/SIGKILL able to
   kill the process, but the port is left polluted (later sessions were
   observed blocking on carrier `ttydcd` / misbehaving until replug).

Not a uftdi.c-local defect: the driver's callback is the stock FreeBSD
u4b code; the bug is the DragonFly ucom core's close/drain ordering
(usb_serial.c:955-975) plus the missing TX-complete wakeup. It manifests
through every ucom-family driver; uftdi is the one bindable on this guest.

## Exploit chain (impact ceiling)

Local: user with dialer-group access (or root) runs the 3-line trigger —
process wedged in close(2) forever, 15/16 of a 1 KiB write silently lost,
port degraded until replug; repeatable. Physical/evil-USB: an FTDI-alike
that NAKs bulk-OUT (or is unplugged mid-write in the right window) removes
the race — every write+close wedges. No memory-safety primitive observed
(the hang is a logic/lifecycle bug; the uftdi data path itself is
memory-safe under the live rig — RX/TX contents verified byte-exact).

## Fix validation

`fix.diff` reorders/bounds the close (drain while `t_oproc` is alive,
`tp->t_timeout` bounded; add the missing `ttwwakeup` on final drain).
Authored against read-only `sys/`, `git apply --check` passes. Not
boot-tested (would require a full GENERIC kernel rebuild; fix_status
not_testable) — mechanism argued line-by-line above.

## Fix status (batch validation)
Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest `make -j6 nativekernel KERNCONF=X86_64_GENERIC` completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.
