Skip to content

fix(config-rom): bus-reset cache lifecycle and generation-wrap fixes - #221

Merged
mrmidi merged 17 commits into
mainfrom
fix/config-rom-suspend-on-reset
Oct 23, 2026
Merged

mrmidi merged 17 commits into
mainfrom
fix/config-rom-suspend-on-reset

Conversation

@mrmidi

@mrmidi mrmidi commented Oct 22, 2026 •

Copy link
Copy Markdown
Owner

Config ROM cache and scanner fixes around bus resets and the 8-bit bus generation wrap.

The bus generation is the raw 8-bit Self-ID value and rolls over from 255 to 0 (OHCI 1.2 Table 11-2), so generation 0 is a real value and generations can only be compared for equality. Linux (ohci.c:2143, -1 as the invalid sentinel) and Apple (IOFireWireController.cpp:2750, scan->generation == fBusGeneration) both do it that way. Several places here used Generation{0} as "none" or ordered generations with < / >.

Builds on gly11's #87 (the first four commits are cherry-picked with -x, authorship kept).

Cache lifecycle across bus resets

  • Suspend cached ROMs on bus reset; selector 14 refuses suspended entries (gly11, fix(config-rom): reject suspended generation exports #87). ExportConfigROM is now exact generation or nothing.
  • Drop the unreachable ValidateROM path (gly11, fix(config-rom): reject suspended generation exports #87).
  • Lookups return copies taken under the lock. FindByNode returned pointers into the store's maps after unlocking, while the driver queue inserts/erases and the user-client queue reads.
  • Drop ROMs suspended by the previous reset. The (generation, node) index grew by one entry per node per reset, and after a wrap a reused key could carry another device's ROM.
  • One FindByNode(gen, node, allowSuspended) with no default, so every caller states whether it accepts a pre-reset ROM.

Generation 0 and the wrap

  • Abort the scan on every reset, including when the previous generation was 0; Abort/IsBusyFor no longer treat 0 as "no scan".
  • Scans at generation 0 complete. ROMScanSession used gen_ == 0 as "not running", so after the 256th reset MaybeFinish never fired and the IRM verify replies were dropped. It now keeps an explicit scanActive_.
  • IsBusyFor uses the generation recorded at Start. The session only learns its generation when its start task runs, so a generation-0 request could be refused as a duplicate in between.
  • GUID cache and unread lookups removed. The GUID index and FindLatestForNode ordered generations with < / >, which is backwards after a wrap. Nothing read either: the export fallback was dropped in 805def0, and FindByGuid, Snapshot, SnapshotByState, Clear had no callers. The store is now a single (generation, node) index; the manual ROM re-read uses EraseByGuid.
  • Removed firstSeen / lastValidated (unread, and filled in using 0 as "unset") and the unused GenerationContextPolicy.

Reuse unchanged ROMs after a reset

  • A node whose bus info block is unchanged completes from the previous generation's ROM. Discovery read every node's whole Config ROM again after each reset (bus info block, root directory, text leaves, unit directories), although the previous copy was in the store. On an LS-9000 that rescan was ~550 ms of the 650 ms between a reset and the SBP-2 reconnect (SBP-2: re-login/reconnect after rediscovery, not on a 100 ms timer #216), and every device on the bus waits for it. Apple reads only the 5-quadlet bus info block and keeps its cached ROM when the block is identical (IOFireWireROMCache::hasROMChanged, IOFireWireROMCache.cpp:240-320; IOFireWireDevice.cpp:727-743). The block carries the GUID, the ROM CRC and the ROM generation a device advances when its ROM changes.
  • The scan gets the ROMs the reset suspended (ConfigROMStore::SuspendedROMs). After the bus info block, the speed probe and the IRM check (both still run: they are bus state, not ROM content), a matching node completes from its copy with the new generation and node ID (UnchangedROMPolicy).
  • Always read in full: any difference in the block; a generation-0 ROM without units (Apple's rule, for devices that publish units late); a copy built around a failed or short read (readIncomplete), so a lost read heals at the next reset; a manual re-read.
  • Only one generation back is kept (the store bound above): a reset that interrupts a scan leaves nothing to compare, and the next scan reads in full.

App

  • ROM Explorer drops a selection from a stale topology. After a reset it compared nothing, polled the old generation until timeout, and could re-select the same node ID on a different device.

Testing

  • C++ suite: 3819 pass, 6 skipped (same skips as main).
  • New tests: generation-0 scan completion with and without the IRM check, generation-0 scan with no remote nodes, busy check before the start task runs (manual-dispatch queue), reset after a generation-0 topology aborts the scan, 255 → 0 store rollover, EraseByGuid, concurrent lookups vs SuspendAll/EraseByGuid, ROM Explorer staleness checks.
  • ROM reuse: a device scanned twice reads only q0 and q2-q4 the second time; a new node number still matches; advanced ROM generation, new header CRC, another GUID, a copy with a failed read, generation 0 without units, and no previous ROMs read in full; the 255 -> 0 wrap reuses; the IRM check and speed probe still run; the scan request carries the suspended ROMs.
  • Every fix was mutation-checked: restoring the old code makes a new test fail.
  • Dext compiles in Xcode for both architectures. Hardware check of normal discovery / ROM export / manual re-read: pending. For the ROM reuse: after a reset with devices attached, each logs bus info block unchanged once (/usr/bin/log show --predicate 'eventMessage CONTAINS "bus info block unchanged"').

🤖 Generated with Claude Code

mrmidi added a commit that referenced this pull request Oct 23, 2026
…o block is unchanged

After every bus reset discovery read each node's whole Config ROM again:
bus info block, root directory, text leaves and unit directories, even
though the previous generation's copy sat in ConfigROMStore. This has
been the case since the first snapshot; the store borrowed Apple's
IOFireWireROMCache states but never the part that skips the read. On an
LS-9000 the rescan was ~550 ms of the 650 ms between a reset and the
SBP-2 reconnect, and every device on the bus waits for it.

Apple reads only the bus info block after a reset and keeps the cached
ROM when the block is byte-identical (IOFireWireROMCache::hasROMChanged,
IOFireWireROMCache.cpp:240-320; resume in IOFireWireDevice.cpp:727-743).
The block holds the GUID, the ROM CRC and the ROM generation a device
advances when its ROM changes. Do the same:

- OnTopologyReady hands the scan the ROMs the reset edge suspended
  (ConfigROMStore::SuspendedROMs).
- After the BIB, the speed probe and the IRM check, a node whose BIB
  equals a previous copy completes from that copy with the new
  generation and node ID (UnchangedROMPolicy, ContinueToRootDirectory).
  The speed probe and IRM check still run: both are per-generation bus
  state, not ROM content.
- Apple's exceptions are kept: a generation-0 ROM without units is read
  again (devices that publish units late), and so is a copy that holds
  only the block.
- A copy built around a failed or short detail read is marked
  readIncomplete and never reused, so a lost read heals at the next
  reset as before instead of being kept.
- A manual reread passes no previous ROMs and reads in full.

Only one generation back is kept (#221's store bound): a reset that
interrupts a scan leaves nothing to compare, and the next scan reads in
full.

Tests: ROMScannerUnchangedROMTests scans a device, then scans it again
with that result as the previous generation: unchanged BIB reads only
q0, q2-q4; new node number still matches; advanced ROM generation, new
header CRC, another GUID, a copy with a failed read, generation 0
without units, and no previous ROMs all read in full; the 255 -> 0 wrap
reuses; the IRM check and the speed probe still run. Store:
SuspendedROMs is the previous generation only, and races the erasing
calls. FSM: Complete is reachable from every step before the root
directory. ControllerCore: the scan request carries the suspended ROMs
(the harness's ROMScanner::Start stub can now route to a recorder).
Every rule mutation-checked (11 mutations, each caught).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gly11 and others added 17 commits October 23, 2026 10:08
SuspendAll() gets its first production caller in this PR, which makes the rest
of the ROM lifecycle worth checking. ValidateROM() is the only transition out
of Suspended and has no callers anywhere — not in the driver, not in tests —
so ROMState::Validated was unreachable and the `state == Validated` arms of
SuspendAll() were dead.

Rediscovery already does the work ValidateROM() was written for. Insert()
overwrites romsByGuid_[guid] wholesale, state included, and romsByGenNode_ is
keyed by (generation, nodeId), so after a reset the new generation gets fresh
keys and the suspended entries are simply never looked up again. Wiring
ValidateROM() up instead would put a second writer on the same entries and
require deciding which one wins when Insert()'s richer-ROM guard declines an
update — a second source of truth for no behavior gained.

Nothing observes the difference today: FindByGuid() does not filter on state,
and FindByNode(gen, nodeId, allowSuspended=false) looks up the current
generation, whose key never matches a suspended entry.

Remove the function, the enum value, and the dead arms, and say in both the
enum comment and ConfigROM/README.md how a device actually comes back. If the
store later needs to report which devices did not return, that closes the
Suspended → Invalid → PruneInvalid loop and deserves its own change with
hardware validation.

(cherry picked from commit 5f22663)
… July

ControllerCoreBusResetTests links ControllerCoreInterrupts.cpp against
local stubs. main's reset path now also calls
CyclePolicyCoordinator::OnCycleTooLong and SpeedPolicy::Reset, so the
cherry-picked test needs stubs for both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FindByNode, FindLatestForNode and FindByGuid returned pointers into the
store's maps after releasing its lock. The store is shared across queues:
discovery inserts and the bus-reset edge suspends on the driver queue,
while ExportConfigROM and TriggerROMRead read on the user-client queue.
Insert already replaces entries in place, and SuspendAll is about to erase
entries, so a caller could read freed memory.

Return std::optional<ConfigROM> copied under the lock. The only production
callers are the two user-client handlers; both are cold paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The per-node index is keyed by (generation, node) and nothing removed old
generations in normal operation, so it grew by one entry per node per bus
reset. Hardware generations are 8 bits and wrap after 255 (OHCI 1.2
Table 11-2): once the generation came back, Insert kept the earlier
"richer" entry under the reused key, so a node could carry a different
device's ROM.

SuspendAll now erases entries that were already suspended before
suspending the current ones, so the index holds at most the previous
generation plus whatever discovery inserts for the new one. Nothing reads
older generations: selector 14 refuses suspended entries and discovery
re-inserts current ROMs. Apple keeps one revalidated cache per device and
accumulates no history (IOFireWireDevice.cpp:708,742).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The bus-reset FSM skipped the scan abort when lastGeneration_ was 0, and
ROMScanner::Abort and IsBusyFor treated Generation{0} as "no scan". The
generation is the raw 8-bit Self-ID value and wraps to 0 after 255 (OHCI
1.2 Table 11-2), so after the 256th reset:

- the next reset left the generation-0 scan running, and its completion
  could still run discovery for a stale generation;
- a second Start at generation 0 (for example a manual ROM read) replaced
  a running discovery scan instead of being refused.

Abort now cancels whatever scan is active. No scan for the new
generation can exist before Self-ID, so any active session at a reset
edge is stale. It returns whether a scan was aborted, and abortCount now
counts real aborts. IsBusyFor compares generations without a 0 special
case.

Tests: a generation-0 scan is refused a second Start and is abortable
(ROMScannerAbortTests), and a reset after a generation-0 topology aborts
the scan (BusResetCoordinatorTests, via a counting scanner stub).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The two-argument FindByNode returned suspended entries without saying so;
calling it from selector 14 is how pre-reset ROMs were exported as
current. Merge the overloads and give allowSuspended no default, so every
caller states whether it accepts a ROM from before the last bus reset.
Production callers already passed it; tests that relied on the permissive
overload now pass true.

Factor the find-or-nullopt copy into CopyAt and use it for FindByNode and
FindByGuid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ROM Explorer took its generation and node IDs from the app's cached
topology. After a bus reset the driver moves on: selector 14 no longer
serves the old generation, and node IDs can be reassigned. The explorer
then reported "not cached", and a Read ROM polled the old generation
until it timed out. Worse, the topology refresh re-selected the same node
ID, which may now be a different device, and left the previous device's
ROM on screen.

- Before a cache load, a ROM read and each poll step, compare the view's
  generation with the driver's (getBusResetCount reports the generation
  of the driver's latest topology). On a mismatch, clear the selection,
  refresh the topology and say why.
- When the topology generation changes, drop the selection instead of
  re-selecting by node ID.

Tests cover the generation-change selection drop, same-generation
refresh, and the staleness check. The driver round trip needs a live
driver and is not covered by host tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ROMScanSession used gen_ == Generation{0} to mean "no scan running".
The bus generation is the raw 8-bit Self-ID value and rolls over to 0
after 255 (OHCI 1.2 Table 11-2), so after the 256th reset a real scan
at generation 0 looked finished from the start: MaybeFinish never fired
the completion, and the IRM verify handlers dropped their replies. A
generation-0 scan with no remote nodes never completed either.

The session now keeps an explicit scanActive_ flag and compares
generations only for equality, as Linux (int generation, -1 as the
out-of-range "invalid", ohci.c:2143) and Apple (scan->generation ==
fBusGeneration, IOFireWireController.cpp:2750) do.

ROMScanner::IsBusyFor read the session's gen_, which is only set when
the session's start task runs on the dispatch queue. Until then every
new session reported generation 0, so a request for generation 0 was
refused as a duplicate. The scanner now records the generation itself
at Start.

Tests: the empty-root-directory completion and IRM verify scans run at
generation 0 as well as a nonzero one; a generation-0 scan with no
remote nodes completes; and a manual dispatch queue shows the busy
check before the start task runs. Each was mutation-checked against
the old sentinel (MaybeFinish, both IRM handlers, IsBusyFor).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ConfigROMStore::Insert filled ConfigROM::firstSeen and lastValidated
when they were Generation{0}, which also misread a real generation 0
as "unset". Nothing in the driver or the app reads either field, and
no producer sets them, so both always equalled rom.gen. Remove the
fields, the fill-in code, and the test fixtures that set them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ConfigROMStore kept a second index by GUID and decided which copy to
keep with it->second.gen.value < romCopy.gen.value. FindLatestForNode
picked "latest" with rom.gen.value > latest->gen.value. The bus
generation is the raw 8-bit Self-ID value and wraps from 255 to 0
(OHCI 1.2 Table 11-2), so after a wrap both orderings are backwards:
the GUID copy stayed at generation 255 for good, and "latest" chose the
suspended entry over the current one.

Nothing reads either result. ExportConfigROM's FindLatestForNode
fallback was dropped in merge 805def0, and the selector 14 contract is
now exact generation or nothing (b098ba6). FindByGuid, Snapshot,
SnapshotByState and Clear have no callers either. The GUID index was
read only by InvalidateROM to find the GUID, then PruneInvalid removed
the entry it had just marked.

The store is now one (generation, node) index with no generation
ordering. TriggerROMRead calls EraseByGuid, which removes the device's
cached ROMs in every generation, as InvalidateROM already did for the
node index, so the manual reread is not refused in favour of a richer
cached copy. ROMState::Invalid had no other use and is gone.

Tests: the FindLatestForNode/FindByGuid tests go with the API; the
node-cache cases keep their FindByNode checks; EraseByGuid removes both
generations of one device and nothing else, and lets the shorter reread
in (mutation-checked: erase nothing, erase Fresh only); a reset across
the 255 -> 0 rollover serves the new generation; the concurrency test
now races lookups against SuspendAll and EraseByGuid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nothing calls it, and it treated Generation{0} as "no generation",
which is wrong after the 8-bit bus generation wraps (OHCI 1.2 Table
11-2). The scan session now tracks an explicit scanActive_ instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The bus-reset suspension test only needs the reset side effect;
the ignored [[nodiscard]] result raised -Wunused-result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…o block is unchanged

After every bus reset discovery read each node's whole Config ROM again:
bus info block, root directory, text leaves and unit directories, even
though the previous generation's copy sat in ConfigROMStore. This has
been the case since the first snapshot; the store borrowed Apple's
IOFireWireROMCache states but never the part that skips the read. On an
LS-9000 the rescan was ~550 ms of the 650 ms between a reset and the
SBP-2 reconnect, and every device on the bus waits for it.

Apple reads only the bus info block after a reset and keeps the cached
ROM when the block is byte-identical (IOFireWireROMCache::hasROMChanged,
IOFireWireROMCache.cpp:240-320; resume in IOFireWireDevice.cpp:727-743).
The block holds the GUID, the ROM CRC and the ROM generation a device
advances when its ROM changes. Do the same:

- OnTopologyReady hands the scan the ROMs the reset edge suspended
  (ConfigROMStore::SuspendedROMs).
- After the BIB, the speed probe and the IRM check, a node whose BIB
  equals a previous copy completes from that copy with the new
  generation and node ID (UnchangedROMPolicy, ContinueToRootDirectory).
  The speed probe and IRM check still run: both are per-generation bus
  state, not ROM content.
- Apple's exceptions are kept: a generation-0 ROM without units is read
  again (devices that publish units late), and so is a copy that holds
  only the block.
- A copy built around a failed or short detail read is marked
  readIncomplete and never reused, so a lost read heals at the next
  reset as before instead of being kept.
- A manual reread passes no previous ROMs and reads in full.

Only one generation back is kept (#221's store bound): a reset that
interrupts a scan leaves nothing to compare, and the next scan reads in
full.

Tests: ROMScannerUnchangedROMTests scans a device, then scans it again
with that result as the previous generation: unchanged BIB reads only
q0, q2-q4; new node number still matches; advanced ROM generation, new
header CRC, another GUID, a copy with a failed read, generation 0
without units, and no previous ROMs all read in full; the 255 -> 0 wrap
reuses; the IRM check and the speed probe still run. Store:
SuspendedROMs is the previous generation only, and races the erasing
calls. FSM: Complete is reachable from every step before the root
directory. ControllerCore: the scan request carries the suspended ROMs
(the harness's ROMScanner::Start stub can now route to a recorder).
Every rule mutation-checked (11 mutations, each caught).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main now reads the bus-reset generation from SelfIDCount.selfIDGeneration
(9aa7682); the reserved 0x06C register is gone from RegisterMap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mrmidi
mrmidi force-pushed the fix/config-rom-suspend-on-reset branch from 06709ae to 33de3f5 Compare October 23, 2026 08:24
@mrmidi
mrmidi merged commit 39560de into main Oct 23, 2026
2 checks passed
@mrmidi
mrmidi deleted the fix/config-rom-suspend-on-reset branch October 23, 2026 10:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants