[GH-ISSUE #30] drvPortServer: opening a data tty (/dev/cuaa, /dev/ttyda) crashes kernel-7 #26

Closed
opened 2026-11-03 22:02:31 -07:00 by gitea-mirror · 1 comment
Owner

Originally created by @raynorpat on GitHub (Sep 25, 2026).
Original GitHub issue: https://github.com/RhapsodiOS/RhapsodiOS/issues/30

Symptom

With the reconstructed PortServer driver loaded on kernel-7 (kernel-154.5.1-7 RELEASE_I386), the first open() of a data tty takes the whole kernel down. Both /dev/cuaa (call-out) and /dev/ttyda (dial-in) do it, every time. ssh and the console stop answering, and nothing is printed.

Apple's DR2 PortServer_reloc on the same guest, with the same ISASerialPort (COM1, 0x3f8/IRQ 4), passes: bytes go both ways through /dev/cuaa.

What works

  • driverLoader D=PortServer loads our driver. It registers PDPseudo, ttyda and pdservd, the same as Apple's.
  • pdservd (Apple's, and ours since 5eca8b43c) makes the same nodes for our driver as for Apple's: ttyda 0, ttyida 64, cuaa 32, cuiaa 96, pdservd 192, rpski01-16 193-208.
  • /dev/pdservd opens. /dev/ttyida (initial-state device) opens, and tcgetattr/tcsetattr on it work.

So the crash is on the data-device open path: ttyiops_acquireSession, ttyiops_init/ttyiops_param, the rx/tx IOForkThreads, and the line discipline open.

Evidence

Uninstrumented build. The CPU ends up at EIP 0x1d in kernel CS (segment base 0xc0000000) in a trap-7 (#NM) fault loop: the frame at 0xe7cdfff0 is 7, 0, 0x1d, 0x8, 0x10202. All general registers are 0 except EDX=0x2015c804, and ESP=0x27ce0004, four bytes past the top of an 8 KB kernel stack. That is the shape of a thread entering at EIP 0 on a fresh stack; the "instructions" from 0 to 0x1d are the real-mode IVT. Identical registers on two runs.

With IOLog probes in ttyiops_open and the rx/tx threads, the open itself completes, then the kernel panics thread_dispatch instead:

TIO open dev=120
TIO acquireSession=0
TIO init / TIO param / TIO param done
TIO l_modem / TIO l_modem done
TIO fork rx
TIO forked rx 2023c800
TIO fork tx
TIO rx start
TIO rx nextEvent
TIO rx event 0              <- rx goes into ttyiops_getData
TIO forked tx 2024c000
TIO l_open
TIO l_open=0                <- open returns 0
TIO tx start
TIO tx dcd e64c017e
TIO tx watch
TIO tx watch=0 e64c017e     <- first watchState:mask: returns at once, state unchanged
TIO tx watch
TIO tx watch=-714 a64c0178  <- IO_R_IO
panic: thread_dispatch

thread_dispatch (kern/sched_prim.c:1030) panics when the outgoing thread's state is not one of the legal combinations, so something has left a thread's scheduler state inconsistent. The -714 is IO_R_IO; ttyiops.m calls -0x2ca "interrupted" in several comments, which it isn't.

Ruled out so far

  • IOForkThread/ioThreadStart: a thread function that returns falls into IOExitThread() (driverkit-3/libDriver/Kernel/generalFuncsPrivate.m), not EIP 0. Both ttyiops_rxFunc and ttyiops_txFunc end in IOExitThread() anyway.
  • thread_sleep, tsleep and thread_block() in kernel-7 all block with a NULL continuation, and simple_lock is live in driverkit kernels (MACH_SLOCKS includes DRIVERKIT). Apple's binary imports the same _thread_sleep, _thread_wait_result and _thread_wakeup_prim.
  • IOPortSession's method cache (initForDevice:) fills its IMP slots in the order the accessors read them.

Next

Compare the functions on this path against the reference; several were never re-verified after the fix pass (reconstruction/ledger.json): ttyiops_acquireSession (unexamined), ttyiops_waitForDCD (unexamined), ttyiops_init (intentional mismatch), -[IOPortSession acquirePort:sleep:], watchState:mask:, and AppleIOPSSafeCondLock's lock/unlockWith:/lockWhen:. The tx thread's watchState:mask: returning at once with the state unchanged looks like the first wrong turn.

Reproduction: private i386 QEMU guest from rhap-i386-bootstrapped.img, COM1 as -serial tcp:..., our PortServer.config installed in /usr/Devices, driverLoader D=PortServer, then open /dev/cuaa with O_RDWR|O_NONBLOCK.

Originally created by @raynorpat on GitHub (Sep 25, 2026). Original GitHub issue: https://github.com/RhapsodiOS/RhapsodiOS/issues/30 ## Symptom With the reconstructed PortServer driver loaded on kernel-7 (kernel-154.5.1-7 RELEASE_I386), the first `open()` of a data tty takes the whole kernel down. Both `/dev/cuaa` (call-out) and `/dev/ttyda` (dial-in) do it, every time. ssh and the console stop answering, and nothing is printed. Apple's DR2 `PortServer_reloc` on the same guest, with the same ISASerialPort (COM1, 0x3f8/IRQ 4), passes: bytes go both ways through `/dev/cuaa`. ## What works - `driverLoader D=PortServer` loads our driver. It registers `PDPseudo`, `ttyda` and `pdservd`, the same as Apple's. - pdservd (Apple's, and ours since 5eca8b43c) makes the same nodes for our driver as for Apple's: `ttyda` 0, `ttyida` 64, `cuaa` 32, `cuiaa` 96, `pdservd` 192, `rpski01`-`16` 193-208. - `/dev/pdservd` opens. `/dev/ttyida` (initial-state device) opens, and `tcgetattr`/`tcsetattr` on it work. So the crash is on the data-device open path: `ttyiops_acquireSession`, `ttyiops_init`/`ttyiops_param`, the rx/tx `IOForkThread`s, and the line discipline open. ## Evidence **Uninstrumented build.** The CPU ends up at EIP `0x1d` in kernel CS (segment base 0xc0000000) in a trap-7 (#NM) fault loop: the frame at `0xe7cdfff0` is `7, 0, 0x1d, 0x8, 0x10202`. All general registers are 0 except `EDX=0x2015c804`, and `ESP=0x27ce0004`, four bytes past the top of an 8 KB kernel stack. That is the shape of a thread entering at EIP 0 on a fresh stack; the "instructions" from 0 to 0x1d are the real-mode IVT. Identical registers on two runs. **With `IOLog` probes in `ttyiops_open` and the rx/tx threads**, the open itself completes, then the kernel panics `thread_dispatch` instead: ``` TIO open dev=120 TIO acquireSession=0 TIO init / TIO param / TIO param done TIO l_modem / TIO l_modem done TIO fork rx TIO forked rx 2023c800 TIO fork tx TIO rx start TIO rx nextEvent TIO rx event 0 <- rx goes into ttyiops_getData TIO forked tx 2024c000 TIO l_open TIO l_open=0 <- open returns 0 TIO tx start TIO tx dcd e64c017e TIO tx watch TIO tx watch=0 e64c017e <- first watchState:mask: returns at once, state unchanged TIO tx watch TIO tx watch=-714 a64c0178 <- IO_R_IO panic: thread_dispatch ``` `thread_dispatch` (`kern/sched_prim.c:1030`) panics when the outgoing thread's state is not one of the legal combinations, so something has left a thread's scheduler state inconsistent. The -714 is `IO_R_IO`; `ttyiops.m` calls `-0x2ca` "interrupted" in several comments, which it isn't. ## Ruled out so far - `IOForkThread`/`ioThreadStart`: a thread function that returns falls into `IOExitThread()` (`driverkit-3/libDriver/Kernel/generalFuncsPrivate.m`), not EIP 0. Both `ttyiops_rxFunc` and `ttyiops_txFunc` end in `IOExitThread()` anyway. - `thread_sleep`, `tsleep` and `thread_block()` in kernel-7 all block with a NULL continuation, and `simple_lock` is live in driverkit kernels (`MACH_SLOCKS` includes `DRIVERKIT`). Apple's binary imports the same `_thread_sleep`, `_thread_wait_result` and `_thread_wakeup_prim`. - IOPortSession's method cache (`initForDevice:`) fills its IMP slots in the order the accessors read them. ## Next Compare the functions on this path against the reference; several were never re-verified after the fix pass (`reconstruction/ledger.json`): `ttyiops_acquireSession` (unexamined), `ttyiops_waitForDCD` (unexamined), `ttyiops_init` (intentional mismatch), `-[IOPortSession acquirePort:sleep:]`, `watchState:mask:`, and `AppleIOPSSafeCondLock`'s `lock`/`unlockWith:`/`lockWhen:`. The tx thread's `watchState:mask:` returning at once with the state unchanged looks like the first wrong turn. Reproduction: private i386 QEMU guest from `rhap-i386-bootstrapped.img`, COM1 as `-serial tcp:...`, our `PortServer.config` installed in `/usr/Devices`, `driverLoader D=PortServer`, then open `/dev/cuaa` with `O_RDWR|O_NONBLOCK`.
gitea-mirror 2026-11-03 22:02:31 -07:00
  • closed this issue
  • added the
    bug
    label
Author
Owner

@raynorpat commented on GitHub (Sep 26, 2026):

Root cause: two reconstruction defects in ttyiops.m

1. ttyiops_convertFlowCtrl overwrote its own saved frame pointer. It passed unsigned char response[2] (at [ebp-2]) to requestEvent:data:, which stores 32 bits. The upper two bytes of that store landed on the low half of the saved EBP, so 0x27d63e04 came back as 0x27d60000. ttyiops_ioctl then ran with a frame pointer 16 KB below its stack. That explains every variant in the report: an epilogue returning through zeroed memory to EIP 0, or writes through [ebp-n] into another thread's kernel stack, which gave thread_dispatch, the suspended idle thread, "assert_wait: already asserted", and a double fault. Apple's code uses a 4-byte slot at [ebp-4]. TIOCGETA and every TIOCSETA* go through it.

Caught with QEMU's gdbstub: a hardware breakpoint at the return of every call in ttyiops_ioctl, comparing EBP with its value at entry. convertFlowCtrl was the only call that returned it changed, to exactly 0x27d60000. The ledger had this function as unexamined, and the reconstruction's built-binary length table showed it as equal length, which a [ebp-2] versus [ebp-4] difference doesn't change.

2. ttyiops_txload sent a selector that doesn't exist. With (1) fixed, the first write panicked with IOPortSession does not recognize selector -enqueueData:bufferSize:transferCount:minCount:. Apple's code sends enqueueData:bufferSize:transferCount:sleep: with the same four arguments. It was the only selector our binary sent that Apple's doesn't: 65 against 64. The ledger had called this function assembly-matched.

Fix and verification

6619654c2 (buffer is now an unsigned int) and 69a5e1871 (selector), on claude/vigilant-franklin-c9fa40.

On the i386 QEMU guest under kernel-7, driverLoader loads the driver and runs our pdservd, which makes the usual nodes. A test program sets raw 9600 8N1 with CLOCAL, writes a line and reads the reply. Bytes went both ways three times in a row: through /dev/cuaa, through /dev/ttyda, and through /dev/cuaa again after close and reopen. The guest stayed up with a clean console.

convertFlowCtrl is now instruction-identical to the reference, making 45 of 113 functions identical. Details are in src/drvPortServer/reconstruction/divergences.md §23 (Findings 90 and 91).

<!-- gh-comment-id:5841877292 --> @raynorpat commented on GitHub (Sep 26, 2026): ## Root cause: two reconstruction defects in `ttyiops.m` **1. `ttyiops_convertFlowCtrl` overwrote its own saved frame pointer.** It passed `unsigned char response[2]` (at `[ebp-2]`) to `requestEvent:data:`, which stores 32 bits. The upper two bytes of that store landed on the low half of the saved EBP, so `0x27d63e04` came back as `0x27d60000`. `ttyiops_ioctl` then ran with a frame pointer 16 KB below its stack. That explains every variant in the report: an epilogue returning through zeroed memory to EIP 0, or writes through `[ebp-n]` into another thread's kernel stack, which gave `thread_dispatch`, the suspended idle thread, "assert_wait: already asserted", and a double fault. Apple's code uses a 4-byte slot at `[ebp-4]`. `TIOCGETA` and every `TIOCSETA*` go through it. Caught with QEMU's gdbstub: a hardware breakpoint at the return of every call in `ttyiops_ioctl`, comparing EBP with its value at entry. `convertFlowCtrl` was the only call that returned it changed, to exactly `0x27d60000`. The ledger had this function as `unexamined`, and the reconstruction's built-binary length table showed it as equal length, which a `[ebp-2]` versus `[ebp-4]` difference doesn't change. **2. `ttyiops_txload` sent a selector that doesn't exist.** With (1) fixed, the first write panicked with `IOPortSession does not recognize selector -enqueueData:bufferSize:transferCount:minCount:`. Apple's code sends `enqueueData:bufferSize:transferCount:sleep:` with the same four arguments. It was the only selector our binary sent that Apple's doesn't: 65 against 64. The ledger had called this function `assembly-matched`. ## Fix and verification 6619654c2 (buffer is now an `unsigned int`) and 69a5e1871 (selector), on `claude/vigilant-franklin-c9fa40`. On the i386 QEMU guest under kernel-7, driverLoader loads the driver and runs our pdservd, which makes the usual nodes. A test program sets raw 9600 8N1 with CLOCAL, writes a line and reads the reply. Bytes went both ways three times in a row: through `/dev/cuaa`, through `/dev/ttyda`, and through `/dev/cuaa` again after close and reopen. The guest stayed up with a clean console. `convertFlowCtrl` is now instruction-identical to the reference, making 45 of 113 functions identical. Details are in `src/drvPortServer/reconstruction/divergences.md` §23 (Findings 90 and 91).
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
github-starred/RhapsodiOS#26
No description provided.