netatalk  4.5.0
Free and Open Source Apple Filing Protocol (AFP) Server
Loading...
Searching...
No Matches
cnid_dbd Wire Protocol

Private TCP/IP protocol between cnid_dbd and the libatalk client linked into afpd / nad.

Valid as Netatalk release 4.5

Transport

Framing

Each request/reply is a fixed-size struct write followed optionally by namelen bytes of variable payload. Receiver reads sizeof(struct cnid_dbd_{rqst,rply}) via readt(), then namelen bytes if non-zero.

Byte order

All struct fields are written in native byte order. Heterogeneous-endian remote-cnid server deployments are not supported. CNIDs are stored as host-encoded network-byte-order uint32_t in the BDB primary table and travel network-byte-order across the wire by virtue of how CNIDs are constructed.

Op codes

Mnemonic Value Description
CNID_DBD_OP_OPEN 0x01 Volume-attach handshake
CNID_DBD_OP_CLOSE 0x02 Reserved
CNID_DBD_OP_ADD 0x03 Allocate or look up CNID for path
CNID_DBD_OP_GET 0x04 Look up CNID by (DID, name)
CNID_DBD_OP_RESOLVE 0x05 Resolve CNID → (parent DID, name)
CNID_DBD_OP_LOOKUP 0x06 Look up CNID by (DID, name, dev/ino)
CNID_DBD_OP_UPDATE 0x07 Update entry metadata
CNID_DBD_OP_DELETE 0x08 Delete a CNID
CNID_DBD_OP_MANGLE_ADD 0x09 Add a mangled-name mapping
CNID_DBD_OP_MANGLE_GET 0x0a Look up a mangled-name mapping
CNID_DBD_OP_GETSTAMP 0x0b Return database stamp
CNID_DBD_OP_REBUILD_ADD 0x0c Rebuilder-only add with forced CNID
CNID_DBD_OP_SEARCH 0x0d Filename substring search (paginated)
CNID_DBD_OP_WIPE 0x0e Drop the database

Reply codes

Mnemonic Value Meaning
CNID_DBD_RES_OK 0x00 Success
CNID_DBD_RES_NOTFOUND 0x01 Lookup miss
CNID_DBD_RES_ERR_DB 0x02 Database / request error
CNID_DBD_RES_ERR_MAX 0x03 CNID space exhausted
CNID_DBD_RES_ERR_DUPLCNID 0x04 Duplicate CNID during rebuild
CNID_DBD_RES_SRCH_CNT 0x05 SEARCH: batch full; more may exist
CNID_DBD_RES_SRCH_DONE 0x06 SEARCH: final batch

SEARCH pagination

For op == CNID_DBD_OP_SEARCH the variable-length payload is:

+---------------------+----------------------+
| srch_offset (4 B, | search-name bytes |
| native order) | (namelen - 4 bytes) |
+---------------------+----------------------+

Daemon validation: namelen >= 4, 0 <= srch_offset <= DBD_SEARCH_MAX_OFFSET (50000), 1 <= name_len <= MAXPATHLEN - 4. The wire-level rqst.namelen <= MAXPATHLEN cap is enforced at etc/cnid_dbd/comm.c — any over-budget request is dropped before it reaches dbd_search. The 4-byte offset prefix consumes part of that budget, leaving MAXPATHLEN - 4 for the search-name. Failures inside dbd_search reply RES_ERR_DB (return value 1 from dbd_search; not fatal).

Daemon-side reply-code semantics (SRCH_CNT vs SRCH_DONE): the daemon emits SRCH_CNT iff dbif_search confirms a (srch_offset + DBD_MAX_SRCH_RSLTS + 1)-th matching entry exists in the secondary index — the cursor walk performs a one-step peek immediately after the buffer fills, and tests the result for (DB_NOTFOUND ∨ prefix-mismatch ∨ engine-error) vs (success ∧ prefix-match). The legacy "answer `SRCH_CNT` whenever `count == DBD_MAX_SRCH_RSLTS`" heuristic is incorrect because it cannot distinguish the boundary case where the matching range contains exactly srch_offset + DBD_MAX_SRCH_RSLTS entries (no further matches; correct answer is SRCH_DONE) from the case with strictly more entries (correct answer is SRCH_CNT). See etc/cnid_dbd/dbif.cdbif_search for the post-fill peek implementation; dbif_search's bool *more out-parameter carries the result up to dbd_search, which keys rply.result on it.

Client (cnid_dbd_find) terminates the pagination loop on:

  1. SRCH_DONE reply (natural completion; more_available = false).
  2. (max_results - total) < DBD_MAX_SRCH_RSLTS (buffer-fit; more_available = true).
  3. offset >= DBD_SEARCH_MAX_OFFSET (offset cap; more_available = true).
  4. time(NULL) >= deadline_ts (10 s budget; more_available = true).
  5. Any error path → return -1 with errno = CNID_ERR_DB.

Pagination is stateless on the daemon side: each request re-opens a fresh BDB cursor and skips srch_offset matching entries. Concurrent ADD/DELETE between batches may produce duplicates or misses; the macOS kMDQuery UI deduplicates by path. Crash-resilient resume: a daemon restart between batches re-walks from the same offset.

Determinism invariant for raw-count assertions: any test fixture that asserts a strict got == N count over paginated results MUST guarantee no concurrent CNID ADD/DELETE on the volume during the query. Concurrent mutation between paginated SEARCH batches can produce duplicates or misses on the DBD backend (stateless cursor re-walk); test530/test531 use static, test-private corpora and long unique filename prefixes for this reason.

Single-threaded daemon, per-volume isolation

The cnid_dbd dispatch loop (etc/cnid_dbd/main.c) is single-threaded. A long paginated search blocks it; other CNID ops on the same volume serialise behind it. Per-batch BDB latency is sub-millisecond on a warm cache; ~100 batches × ~1 ms ≈ 100 ms typical for a 10000-result search. The 10 s end-to-end deadline is the hard backstop.

cnid_metad forks one cnid_dbd child per volume (keyed on volume path; see maybe_start_dbd in cnid_metad.c). All afpd workers attached to the same volume share that one child. cnid_metad respawns any dead child on the next client request.

Wall-clock bound. DBD_FIND_DEADLINE_SEC is best-effort. The deadline is checked at the top of each batch iteration; once transmit_locked() is called, it may take up to MAX_DELAY seconds (= 20 s) to resolve a flaky connection via its internal reconnect loop. The hard upper bound on cnid_dbd_find() is therefore approximately DBD_FIND_DEADLINE_SEC + MAX_DELAY = 30 s, not 10 s. The typical case is far below either bound.

Cancellation

The Spotlight pipeline runs synchronously in the AFP worker. While cnid_find() is in flight the worker is blocked in transmit_locked() and is not reading the DSI socket; an in-flight closeQueryForContext: cannot reach slq_cancel() until pagination returns. Worst-case wasted work for a cancelled query is DBD_FIND_DEADLINE_SEC = 10 s (or up to ~30 s in the presence of a daemon reconnect, per the wall-clock note above). Async cancellation requires architecture changes beyond this protocol and is a future work item.

Upgrade contract

This wire format is the private TCP/IP RPC spoken between three components: the libatalk CNID client (linked into afpd and nad), the per-volume cnid_dbd backend daemon, and the cnid_metad supervisor that forks cnid_dbd instances on demand. All three sides must agree on the on-the-wire layout of struct cnid_dbd_rqst, struct cnid_dbd_rply, and the SEARCH-payload prefix described above.

Wire-format changes are therefore a hard break - Netatalk does not support mixed-version deployments: every host that runs any of the components must be upgraded together, and the running process group must be restarted so the new binaries take effect. Same-host deployments just restart Netatalk which restarts all three components; remote cnid server = <host> deployments must restart cnid_metad on remote host as well.

Changes

Netatalk 4.5 release note; wire change is SEARCH-only — non-SEARCH opcodes are byte-identical to the previous release. Any unexpected SEARCH reply code from the daemon is a protocol violation and abort()s the AFP worker, matching the convention of every other cnid_dbd_* op.