Skip to content

Ask the doxm lookup first, and say which silent-directory case the bridge saw - #118

Merged
QuiteYellow merged 3 commits into
mainfrom
discovery/doxm-first-and-directory-log
Oct 4, 2026
Merged

QuiteYellow merged 3 commits into
mainfrom
discovery/doxm-first-and-directory-log

Conversation

@QuiteYellow

@QuiteYellow QuiteYellow commented Oct 4, 2026 •

Copy link
Copy Markdown
Owner

Closes #94. Closes #111.

Two fixes from my own issues, plus a third commit for the fallthrough rule that the doxm-first change left pointing the wrong way. The discovery numbers below were measured on the dryer and the oven here.

Ask the doxm lookup first (#94)

discover_ocf_secure_ports read the whole /oic/res directory first and only fell back to ?rt=oic.r.doxm when that advertised no secure port. Every request the module sends asks with Accept 60, the OIC 1.1 dialect, which has no eps key, so the eps entry the unfiltered pass leads with cannot appear in any answer these two appliances give. Accept 10000 is the dialect that would carry one, and both refuse it with 4.06, the oven on plaintext and DTLS alike (2026-09-14).

Both advertised forms are read from whichever representation arrives, so the order changes no coverage. What it changes is the cost, measured 2026-10-04:

dryer oven
GET /oic/res 1727 B, 2 blocks, 2 attempts 1629 B, 2 blocks, 2 attempts
GET /oic/res?rt=oic.r.doxm 149 B, 1 block, 1 attempt 147 B, 1 block, 1 attempt
discovery, doxm-first attempts=1 → 49155 attempts=1 → 49154
discovery, directory-first attempts=2 → 49155 attempts=2 → 49154

Same port either way, in half the requests. One correction to the issue as filed: it described the unfiltered pass as fetching and discarding a 9 KiB directory, a figure vmonkey measured on their board in #36. These two appliances serve under 2 KB, so the saving here is the round trip and not the bytes.

Both lookups stay, because a device advertising eps on a link other than doxm is reachable only through the unfiltered directory. The choice is a new order parameter, defaulting to 'doxm-first', with 'directory-first' reversing the two.

The filtered lookup's share of timeout is one second, or half of a shorter timeout. Running first it is capped there, so the directory behind it keeps the rest of the deadline; running second it is guaranteed that much instead, because the directory ahead of it stops early to leave it. A single rule would be wrong in one of the two orders, and keeping the old one unchanged would have handed the Block2 transfer the scrap.

Docstrings name the dialect each request asks in and record the 4.06, so the next reader does not switch dialect to draw out an eps and remove the directory instead. DtlsCoapSession manages ACCEPT for that same reason, which is now written down where it is enforced.

Read the directory when the first lookup answers unusably

Putting the narrow lookup first left it behind the fallthrough rule the old order had written. discover_ocf_secure_ports went on to the second lookup for an unanswered first request and for a valid representation carrying no usable port, and returned for everything else. That cost nothing while the directory ran first, because the directory had already seen every link the device advertises.

Behind the filtered lookup it costs two states. A transfer answered in part and never finished is labelled malformed, which is indistinguishable from bad content at the call site, and the filtered lookup's share is a hard cap when it runs first: a slow narrow answer therefore ended the whole operation inside a fraction of the deadline with the directory never asked. A filtered answer carrying only a cross-source eps ended it the same way. Neither appliance here produced either state in the runs above, where the filtered lookup answered with a usable port on the first attempt, so both are reasoned from the code rather than measured.

Only a usable port ends the operation now, apart from endpoint_unavailable, where the sends themselves failed and a second lookup over those routes would ask nothing. A malformed transfer, undecodable CBOR and a cross-source eps each record a diagnosis in first_error and go on to the second lookup, which reports it when no port comes back there either.

#36 added this function and listed "malformed, partial, stale-token, and cross-source responses fail closed" among its bounds, so the early return had a stated intent. The stale-token item on that list says what it meant: classify_coap_response returns RESPONSE_IGNORE for a token mismatch, so a stale answer to the first lookup leaves it recording no response at all, and the second lookup has always run. test_non_request_rejects_piggyback_ack_and_uses_fallback pins that shape for a non-request response. Failing closed there is refusing the content of an answer rather than abandoning the operation, and that is what this keeps: nothing from a failed lookup is carried forward, and every port the second one yields faces the same source check.

Two notes on the section above. 'directory-first' reverses the lookup order and inherits this rule, so under it a partial directory read also goes on to the filtered lookup, where before this PR it returned. Neither value reproduces the old behaviour exactly. And the order table is selected by value, where tuple identity happened to work only because the table stores one shared object, so a third order written with an inline query literal would have taken the directory's budget in silence.

Name which silent-directory case the bridge saw (#111)

_advertised_ports logged one sentence for every result carrying no secure port: directory on 5683 advertised no secure port. On #110 that line was quoted for a dryer that had not answered on 5683, where the wording invites the reading that the appliance has no secure port.

Three outcomes reach that branch, and it now says which. attempts == 0 separates a request that was never sent, since discovery reports endpoint_unavailable with no attempts when the host does not resolve or no route opens. response_received then separates a device that was asked and stayed silent from one that answered carrying no usable secure port. The repr stays on all three.

The answered case reads "a lookup on 5683 answered with no secure port". The issue proposed "directory on 5683 advertised no secure port"; with two lookups either one can be the one that answered.

Two addresses on the bridge host that answer nothing return response_received=False with error_code='no_ocf_response', the result that used to print the wrong sentence. The test fake tied response_received to the port list, so the branches could not be told apart in tests; it now takes the flag and the attempt count.

Checks

947 tests pass on 3.12, and the tests covering the files this touches pass on 3.11. docs/api.md is regenerated and share safety is clean.

The bridge ran against both appliances for the discovery numbers above, at the first two commits: same ports as before, both sessions connected and seeded, 0 errors and 0 timeouts in the first poll window. The third commit changes only the paths where the first lookup fails to yield a port, and on both appliances it yielded one, so that run did not reach it. Tests cover those paths instead.

_advertised_ports logged one sentence for every result carrying no
secure port: "directory on 5683 advertised no secure port". Two
different outcomes reach that branch. One is a directory that answered
and held nothing usable. The other is response_received=False, where
nothing came back at all: asleep, silent on the plaintext port, or
unreachable from the container.

On issue #110 a reporter quoted that line for a dryer that had not
answered on 5683, and read it as an appliance with no secure port. The
comment above the call said the repr separated the two cases, which put
the distinction in the operand list of a sentence that had already made
the call.

Branch on response_received and name which happened. The repr stays on
both, since the attempt count and error code still carry the detail.
The test fake tied response_received to the port list, so the two
branches could not be told apart in tests; it now takes the flag.

Run against the bridge host, two addresses that answer nothing both
return response_received=False with error_code='no_ocf_response' --
the result that used to print the wrong sentence.
discover_ocf_secure_ports read the whole /oic/res directory first and
fell back to ?rt=oic.r.doxm only when that advertised no secure port.
Every request this module sends asks with Accept 60, the OIC 1.1
dialect, which has no eps key -- so the eps entry the unfiltered pass
leads with cannot appear in any answer these appliances give. Accept
10000 is the dialect that would carry one, and both appliances here
refuse it with 4.06, the oven on plaintext and DTLS alike (2026-09-14).
The directory pass was paying for a representation it could not use.

Both advertised forms are read from whichever answer arrives, so the
order changes no coverage. Measured on the two appliances here on
2026-10-04: the filtered lookup answers in 149 and 147 bytes in a
single datagram, where the unfiltered directory takes 1727 and 1629
bytes over two Block2 blocks. Discovery now resolves the same secure
port in one request instead of two, on both.

Both lookups stay, because a device advertising eps on a link other
than doxm is reachable only through the unfiltered directory. The
choice is exposed as order, defaulting to doxm-first.

The time budget follows the lookup rather than its position: the
filtered request is capped at a second, and at half of a shorter
timeout, so a directory read behind it keeps the rest of the deadline.
Reversing the old rule instead would have left the Block2 transfer the
scrap.

The docstrings name the dialect each request asks in and record the
4.06, so the next reader does not switch dialect to draw out an eps and
remove the directory instead. DtlsCoapSession manages ACCEPT for that
same reason, which is now written where it is enforced.
@QuiteYellow

QuiteYellow commented Oct 4, 2026 •

Copy link
Copy Markdown
Owner Author

Reviewed this against the branch before merging it. The reordering is sound for the two appliances here, and the measurements in the description hold. What it does not account for is the fallthrough rule, which the swap leaves pointing the wrong way.

The second lookup is now unreachable for the device it was kept for

discover_ocf_secure_ports falls through to the second lookup for two outcomes only: a first request that went completely unanswered, and a valid representation carrying no usable port. Every other first-lookup outcome returns straight away. That is unchanged from main, and on main it cost nothing, because the lookup holding the veto was the directory, which had already seen every link the device advertises. The veto now sits with the narrow lookup.

That rule arrived with #36, which added this function, and it arrived with a reason: the description lists "malformed, partial, stale-token, and cross-source responses fail closed" under its bounds and safety heading. So the question is what failing closed was taken to mean there, and two other items on that same list answer it. A stale token and a rejected piggyback ACK each discard the response and go on to the second lookup, and the tests added in that commit pin the behaviour by asserting a port comes back over two attempts: test_stale_primary_token_is_ignored_during_fallback and test_non_request_rejects_piggyback_ack_and_uses_fallback. Failing closed on that list means refusing the content of an answer. The early return goes further and ends the operation, which is a second thing, and it is the part the doxm-first default now rests on.

So _PORTS_UNTRUSTED or _PORTS_MALFORMED from ?rt=oic.r.doxm returns malformed_ocf_response at line 920 and the directory is never asked. _ports_from_links reaches _PORTS_UNTRUSTED (line 531) whenever a link carries an eps bound to some address other than the response source and no p.sec/port follows it. A dual-homed device advertising the other NIC on its doxm link, with the usable eps on /oic/d, resolved in one request on main and returns no ports here. Undecodable CBOR in the filtered answer aborts the pair the same way.

That device is the one three places in this branch say the directory is kept for: the description above, the module docstring at line 24, and docs/appliance-compatibility.md line 67, all in the same words: a device advertising eps on a link other than doxm is reachable only through the unfiltered directory. That sentence describes the code path accurately. The default now routes around it.

This shape is absent from both appliances here. Each answers the filtered lookup with a p.sec port in 147 and 149 bytes, and each answers 4.06 to the dialect that would carry an eps at all. So what the swap costs is the path's cover, and the two devices in hand stay where they were. Falling through on _PORTS_UNTRUSTED and _PORTS_MALFORMED from the first lookup, and returning the error only if the second also fails, restores it without touching the order.

A slow filtered answer takes the directory down with it

Line 913 returns early on _TRANSFER_MALFORMED, and _fetch_resource labels any transfer that was answered in part and never completed _TRANSFER_MALFORMED (line 724, if response_received or saw_malformed). A filtered answer that needs longer than its budget is therefore reported as malformed, and the directory behind it is never asked.

The budget makes that reachable. filtered_share is min(1.0, timeout / 2) and line 890 spends it as started + filtered_share, so the cap is absolute: timeout=30 still gives the filtered lookup one second, and raising timeout widens the directory's share alone. Discovery can give up 1.0 s into a 3.0 s deadline. Same fix as above: a transfer that ran out of time should hand off to the second lookup.

endpoint_unavailable prints the sentence #111 was about

response_received=False covers more than a device that stayed quiet. Lines 871 and 877 return it with attempts=0 when name resolution fails or no route opens, where nothing was sent at all. bridge.py line 441 then logs no answer from the directory on 5683, which says the appliance was asked and did not reply, about a host that was never reached. #111 is that exact misreading, so this case wants its own branch, or attempts == 0 folded into the condition. The repr carries error_code='endpoint_unavailable' either way, which is what keeps it recoverable for whoever reads the log.

response_received cannot answer the question the log asks it

It is OR'd across both lookups at line 940, so it reports whether either lookup answered. The log asks it which one. Under the new default, a device that answers ?rt=oic.r.doxm with no usable port and then never answers the directory returns response_received=True with error_code='no_ocf_response', and line 437 logs directory on 5683 advertised no secure port next to that code. The line contradicts itself about a read that came back empty. Naming the outcome needs a per-lookup signal.

Two smaller ones

The comment at line 886 and the docstring at line 857 both say the filtered lookup is capped at one second wherever it sits in the order. Under directory-first it runs second with cutoff=deadline (line 934) and takes whatever the directory left, so one second is its reserved floor there, where the comment reads as a ceiling. Two rules in the wording of one.

Line 889 selects the budget with first_query is _FILTERED_QUERY. That holds only because _LOOKUP_ORDERS stores the same tuple object; a third order written with an inline (b'rt=oic.r.doxm',) would take the else branch and get the directory's budget instead. == costs nothing in a table added to be extended.

Where that leaves it

The two fallthrough findings are one change, and it also settles what the line 886 comment should say, since the budget stops being a dead end once a lookup that runs out of time can hand off. The logging findings are independent of it.

Making ?rt=oic.r.doxm the first lookup left the fallthrough rule
pointing the wrong way. discover_ocf_secure_ports went on to the second
lookup for an unanswered first request and for a valid representation
carrying no usable port, and returned for everything else. That cost
nothing while the directory held the veto, because it had already seen
every link the device advertises. Behind the narrow lookup it makes a
device advertising its only trustworthy eps outside doxm unreachable,
which is the device the module docstring and
docs/appliance-compatibility.md both say the directory is kept for. A
filtered answer that merely ran out of its share of the budget is
labelled malformed as well, so that ended the operation the same way,
inside a fraction of the deadline.

Only a usable port ends the operation now. A malformed transfer,
undecodable CBOR and a cross-source eps from the first lookup each
record a diagnosis in first_error and go on to the second, which
reports it when no port comes back there either. endpoint_unavailable
still returns, because sends themselves failed and a second lookup over
those routes would ask nothing.

#36 added this function and listed "malformed, partial, stale-token,
and cross-source responses fail closed" among its bounds, so the early
return had a stated intent. Two items on that list fix what it meant: a
stale token and a rejected piggyback ACK each discard the response and
go on to the second lookup, which the tests added in that commit pin by
asserting a port comes back over two attempts. Failing closed there is
refusing the content of an answer, and that is what this keeps. Nothing
from a failed lookup is carried forward, and every port the second one
yields faces the same source check.

Two descriptions of the time budget said cap where the second order
means floor: the filtered lookup is capped at its share running first,
and guaranteed that share running second, because the directory ahead
of it stops early to leave it. The order table is selected by value,
where tuple identity happened to work only because the table stores one
shared object, so a third order written with an inline query literal
would have taken the directory's budget in silence.

_advertised_ports gains a third branch. endpoint_unavailable with no
attempts means the host did not resolve or no route opened, where "no
answer from the directory" claims the appliance was asked.
@QuiteYellow
QuiteYellow merged commit 033dae9 into main Oct 4, 2026
8 checks passed
@QuiteYellow
QuiteYellow deleted the discovery/doxm-first-and-directory-log branch October 4, 2026 14:44
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.

Bridge says "advertised no secure port" when the directory never answered discover_ocf_secure_ports asks in a dialect that cannot carry eps

1 participant