Skip to content

DSI Quantum optimisations, alignments and performance regression fixes#3196

Open
andylemin wants to merge 1 commit into
mainfrom
dsi-quantum
Open

DSI Quantum optimisations, alignments and performance regression fixes#3196
andylemin wants to merge 1 commit into
mainfrom
dsi-quantum

Conversation

@andylemin

@andylemin andylemin commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

The DSI server quantum governs how much file data a client moves per FPWrite/FPWriteExt request, and — because clients derive their read chunk size from it — per read as well. The recent DSI frame-parsing hardening (CVE-2026-62319 / CVE-2026-62320, commit cc0a9d16) correctly rejects any frame whose total size exceeds the negotiated quantum. But the server was measuring the wrong thing: it bounded the whole DSI frame (header + request block + data) by the quantum, when the AFP spec defines the quantum as the data size only. A spec-legal write of exactly quantum data bytes therefore had a frame of quantum + doff, which the hardened parser tore down as oversized.

The tools papered over this by subtracting the frame overhead from every transfer (PR #3186, PR #3187), so clients never used the full negotiated quantum in either direction. That restored correctness but gave up throughput — a workaround for a server-side accounting bug, not a real protocol limit.

This PR fixes the accounting at the source, restores full-quantum transfers, aligns the advertised quantum to the connection's TCP MSS so no frame ends in a runt segment, and removes a redundant userspace copy on the write receive path. Along the way it closes an out-of-bounds read in the FPAddIcon write path. All of the CVE hardening stays in place and is tightened.

Root cause

Per the AFP specification, the server request quantum is the size of the write data alone. DSIOpenSession, Table 1:

DSI Option Server Request Quantum … the maximum size of data that the server is prepared to receive as part of a request, not including the length of the DSI header and the AFP command.

and DSIWrite:

the amount of data to be written may be up to the size of the server request quantum.

The server instead enforced the quantum against the entire frame. In dsi_stream_receive the check if (dsi_len > dsi->server_quantum) ran before any WRITE/doff handling, so for a write frame dsi_len was data + doff and a full-quantum write (data == quantum) was rejected. Because clients size their read requests from the advertised quantum too, the client-side workaround (subtracting frame overhead) degraded read throughput as well as write.

Two further defects on the write path:

  • Redundant copy + latent truncation. dsi_writeinit memmoved the already-buffered payload prefix out of the read-ahead buffer into the caller's buffer (a second userspace copy of up to the read-ahead size per write), and its memmove(buf, start, MIN(buflen, bytes)) paired with an untruncated bytes return silently dropped data whenever the prefix exceeded the caller's buffer.
  • Client runt segments. The test client sent each write frame as two syscalls — the header+request block via dsi_stream_send, then the payload via dsi_stream_write — so with TCP_NODELAY a runt segment preceded every payload.

Model

The server quantum counts FPWrite/FPWriteExt data only; the 16-byte DSI header and the request block (12 bytes FPWrite, 20 bytes FPWriteExt) ride on top of it on the wire.

The session quantum is resolved once per session, immediately after accept() (before any buffer is allocated), from the configured value and the socket's measured MSS:

the smallest 4 KiB multiple at or above the configured quantum whose FPWriteExt frame (data + 36 bytes) ends in a TCP segment at least 7/8 full — growing by at most 256 KiB, capped at 256 MiB (DSI_SERVQUANT_MAX). When no such multiple is found within the budget, the best-fill candidate wins; when no usable MSS is detected, the value is page-aligned only.

Growing (rather than rounding down) means the client never gets less than the administrator configured. Page alignment keeps the data a 4 KiB multiple; the 7/8-fill threshold is used because exact dual alignment is impossible when gcd(4096, mss) does not divide 36 (e.g. MSS 1448 or 8960). Buffers, inbound checks, and the advertised value all use the grown quantum, so advertised == capacity and every client frame fits the server's buffers exactly.

MSS is read from TCP_INFO (Linux tcpi_snd_mss) or TCP_CONNECTION_INFO (macOS tcpi_maxseg), falling back to TCP_MAXSEG; live values already account for active TCP options. Detection self-disables to page-alignment-only where no socket option is available.

Fixes

Server receive bound (libatalk/dsi/dsi_stream.c). For DSIFUNC_WRITE, the data offset is validated against an exact {12, 20} whitelist, then doff <= dsi_len, then dsi_len - doff > server_quantum bounds the data. Non-write commands keep the strict dsi_len > server_quantum bound. This restores full-quantum transfers — a client may again send, and request, a complete server_quantum of data per frame.

Zero-copy write handoff (libatalk/dsi/dsi_write.c). dsi_writeinit now hands the buffered payload back by reference (char **), or drains it when passed NULL. The prefix is consumed in place — one copy, no truncation. afp_addicon's error path drains and returns the AFP error instead of falling through and re-arming the transfer. Signature change ⇒ soversion 20 → 21.

Single-writev client (test/testsuite/afpclient.c). dsi_stream_send3 sends header, request block, and payload in one writev, so no runt segment precedes the payload. speedtest gains an FPWriteExt mode (default for AFP ≥ 3.0, matching macOS; -E selects legacy FPWrite), and both speedtest and lantest size transfers from the full advertised quantum — no overhead subtraction, and a guard against a zero (unnegotiated) quantum.

Config (libatalk/util/netatalk_conf.c). dsireadbuf default 12 → 32, capped so dsireadbuf × quantum stays under 1 GiB per session (NETATALK_READBUF_LIMIT, re-clamped in the child after the quantum grows). Server-quantum values above the maximum now clamp to DSI_SERVQUANT_MAX instead of silently falling back to the 1 MiB default.

Security

The CVE-2026-62319 / CVE-2026-62320 DSI frame-parsing hardening (cc0a9d16) stays fully in place and is strengthened:

  • The write data offset is now validated against an exact {12, 20} whitelist — malformed offsets 13–19 are rejected, not parsed into a nonsense request block.
  • The doff <= dsi_len bound and the ASC-3.7 zero-doff workaround are preserved.
  • dsi_tcp_open's first-frame whole-frame check is unchanged: a hostile full-quantum WRITE as the first frame is still killed there (conforming clients always send DSIOpenSession first).
  • Nothing is clamped, truncated, or fallen back from — out-of-bounds input is rejected, in the server and in the tools.

This supersedes the frame-overhead-subtraction workarounds from PR #3186 / PR #3187 by fixing the server semantics they compensated for — restoring full-quantum throughput — while preserving their invariant that the client never emits a frame the hardened parser rejects.

Additional vector closed — FPAddIcon out-of-bounds read

The old dsi_writeinit truncated the buffered prefix into the caller's buffer via memmove(buf, start, MIN(buflen, bytes)) but returned the untruncated byte count. afp_addicon then wrote that count from a 64 KiB (DSI_DATASIZ) reply buffer — reading past the buffer and emitting stale heap bytes into the icon file whenever a buffered write prefix exceeded 64 KiB against the 1 MiB default quantum. The pointer handoff returns the exact buffered range, removing the over-read.

Behaviour changes

  • Full-quantum FPWrite/FPWriteExt is accepted again; transfers use the complete advertised quantum in both directions.
  • The advertised quantum is MSS-aligned per session and logged at session open (e.g. dsi_tcp_open: MSS 32768, session quantum 1077248).
  • server quantum maximum is 256 MiB (was 4 GiB); out-of-range configs clamp to the max (above) or the default (below the 32000 minimum).
  • dsireadbuf default is 32 (max 1024), with the per-session product capped at 1 GiB.
  • No on-wire or config change is required of existing deployments; the quantum is renegotiated on every DSIOpenSession.

Testing

afpd unit tests (test/afpd/test.c)

test asserts
utest_dsi_receive_accepts_full_quantum_write (doff 12 & 20) a full-quantum write in one record is accepted
utest_dsi_receive_rejects_overquantum_write (doff 12 & 20) one data byte past the quantum is rejected
utest_dsi_receive_rejects_overquantum_cmd non-write commands keep the strict whole-frame bound
utest_dsi_receive_validates_doff doff outside {12, 20} rejected; doff==0 maps to 12
utest_dsi_writeinit_no_duplicate_copy buffered payload handed off by reference, not copied
utest_dsi_align_quantum growth, page alignment, fill threshold, 256 KiB / 256 MiB caps, and no uint32 overflow at the top of the range
utest_readbuf_clamp dsireadbuf × quantum bounded, floored at 1

The two server bugs landed as RED tests one commit ahead of their fixes; those failing CI runs are preserved in this PR's checks history as the RED evidence, before the branch was squashed.

Integration

  • AFP spectest (single-container loopback): 341 pass / 0 fail; FPWriteExt full-quantum writes round-trip at the grown advertised size.
  • afp_speedtest and afp_lantest complete against the built server in both FPWriteExt and legacy -E modes.
  • The speedtest dashboard gains avg mean and avg max columns across the file-size sweep, so per-round-trip regressions on small transfers are visible alongside large-file peaks.

Not in this PR (follow-ups)

  • Backport of the throughput regression fix to the stable 4.5 branch.
  • Widening the legacy-mode 32-bit offset/size loop variables in lantest (pre-existing, out of scope).

Quantum/MSS scaling validation;
packet-size-compare

@andylemin
andylemin requested a review from a team July 21, 2026 05:56
@andylemin
andylemin requested a review from rdmark as a code owner July 21, 2026 05:56
@augmentcode

augmentcode Bot commented Jul 21, 2026

Copy link
Copy Markdown
🤖 Augment PR Summary

Summary: This PR tightens DSI quantum handling (spec-correct write semantics), eliminates extra buffered-write copying, and updates perf tooling/tests to reflect the corrected behavior.

Changes:

  • DSI receive path now bounds write data ("len - doff") by the negotiated quantum and validates write offsets (12/20, with ASC 3.7.x doff=0 workaround).
  • dsi_writeinit() now hands back buffered write payload by pointer (no memmove/copy) and the libatalk soversion is bumped to 21.
  • Session quantum is page-aligned and optionally MSS-shaped (bounded growth) to avoid runt TCP segments; server quantum is capped at 256 MiB.
  • dsireadbuf default is raised to 32, capped at 1024, and clamped so dsireadbuf * quantum stays within a 1 GiB per-session ceiling.
  • Speedtest/lantest and the testsuite client are updated to send write frames in a single writev and to treat quantum as pure data capacity (with an option to force legacy FPWrite).
  • CI speedtest summary reporting is expanded (peak/avg mean/avg max), docs/NEWS are updated, and the Alpine testsuite Dockerfile kills gpg-agent after verification.

Technical Notes: The main semantic change is that the configured/negotiated quantum constrains AFP write payload bytes, while request-block/frame overhead rides on top of that on the wire.

🤖 Was this summary useful? React with 👍 or 👎

@augmentcode augmentcode Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review completed. 2 suggestions posted.

Fix All in Augment

Comment augment review to trigger a new review at any time.

Comment thread test/afpd/test.c
Comment thread test/afpd/test.c
@andylemin
andylemin force-pushed the dsi-quantum branch 4 times, most recently from 6d88b3b to b463fad Compare July 21, 2026 12:40
@andylemin

andylemin commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

@rdmark Performance fully recovered, security patches maintained and one additional vector closed.
All VFS/DSI/NET layers now aligned..

This is not ready for merge - I am creating a new performance test rig which runs the speedtest and lantest tests;

  • Run inside the existing spectest image under docker: afpd starts normally in the container's root namespace; the script creates a client namespace, joins them with a veth pair, and shapes both directions with tc netem.
  • Network speed is configurable via env: RATE (e.g. 100mbit, 1gbit, 10gbit), DELAY (one-way; RTT is 2×), MTU (1500/9000), plus SIZE/ITER/MODE (FPWriteExt vs -E legacy vs both).
  • Reports speedtest throughput per run, a mid-run ss -ti sample (connection MSS + segs_out), and the client namespace's total TCP OutSegs delta per run.

Ie, the purpose is to provide a static network test environment, rather than the CPU bound loopback based one the performance runners currently use. This will show performance on different real networks much more consistently, and will show the number of packets exchanged, exposing any runt and alignment issues.

We need to merge that PR before this one, so we have a before/after this PR for a true concrete comparison?

@rdmark

rdmark commented Jul 21, 2026

Copy link
Copy Markdown
Member

impressive work, this turned out to be a quite big changeset! do you think it's feasible to backport the performance regression to the stable 4.5 branch? I think it's worth putting out another bugfix release for this.

it makes sense to hold off on merging this while you introduce new testing capabilities to properly prove the improvement

@andylemin

Copy link
Copy Markdown
Contributor Author

impressive work, this turned out to be a quite big changeset! do you think it's feasible to backport the performance regression to the stable 4.5 branch? I think it's worth putting out another bugfix release for this.

The key parts yes (byte accounting and the extra security fix). This includes several changes which do not need to be back-ported (such as the MSS alignment, which adjusts the quantum value to a figure which is divisible by the MSS, mitigating the runt packet at the end of every single DSI record).

it makes sense to hold off on merging this while you introduce new testing capabilities to properly prove the improvement

Absolutely, This one needs to be grounded in evidence as there is a lot of hard theory work here.

@andylemin

andylemin commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

Performance BEFORE quantum security hardening;
pr-3180

Performance AFTER quantum security hardening (more secure but performance regression);
pr-3193

Performance AFTER quantum security hardening and AFTER this fix
pr-3196

All test files sizes bellow 8-16MiB have significant performance loss, as every single test payload is a multiple of KiB/MiB, however the server DSI quantum became un-aligned during the security fixes, resulting in a runt DSI record for every single DSI record stream.
1MiB = 2 DSI Records
2MiB = 3 DSI Records
4MiB = 5 DSI Records
8MiB = 9 DSI Records (runt overhead (1/8) begins to diminish)

@andylemin

Copy link
Copy Markdown
Contributor Author

@rdmark It looks bigger than it is due to the number of commits - mostly unittests due to the critical nature of the code. I added tests first (to ensure they failed) and then confirm they turned green after adding the respective fixes.

Once it is squashed it will be smaller and easier to review.

I have just modified the quantum alignment algorithm to scale up (instead of down) to the nearest value which is divisible by 4KiB and aligns the DSI record size with the connections negotiated MSS ensure no runt packets at the end of each record. So there is some churn which will vanish

@andylemin

Copy link
Copy Markdown
Contributor Author

augment review

@augmentcode augmentcode Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review completed. 2 suggestions posted.

Fix All in Augment

Comment augment review to trigger a new review at any time.

Comment thread etc/afpd/afp_dsi.c Outdated
Comment thread test/testsuite/afpclient.c
@andylemin
andylemin force-pushed the dsi-quantum branch 2 times, most recently from 8371c61 to b23227e Compare July 22, 2026 11:13
Problems: dsi_stream_receive bounded the whole DSI frame by the server
quantum, so a spec-legal FPWrite carrying exactly quantum data bytes
(dsi_len = quantum + doff) tore down the session. To stay under that
bound the tools subtracted the frame overhead from every transfer, so
clients never used the full negotiated quantum in either direction --
a throughput loss on both writes and reads from incorrect header
accounting, not a real protocol limit. dsi_writeinit copied the
buffered payload prefix into the caller's buffer (a second userspace
copy of up to the read-ahead size per write) and silently truncated
when the prefix exceeded the caller's buffer. The test client sent
each write frame as two syscalls, leaving a runt TCP segment before
every payload; transfers also ended in a runt segment whenever the
quantum was not aligned to the path MSS.

Model: per the AFP spec (DSIOpenSession, Table 1: the request quantum
is the write data size 'not including the length of the DSI header and
the AFP command'), the server quantum counts FPWrite/FPWriteExt data
only; the 16-byte DSI header and the request block ride on top. The
session quantum is resolved once per session after accept(): the
smallest 4 KiB multiple at or above the configured value whose
FPWriteExt frame (data + 36 bytes) ends in a TCP segment at least 7/8
full, growing by at most 256 KiB, capped at 256 MiB; best fill wins
when the threshold is unreachable. Buffers, inbound checks, and the
advertised value all use the grown quantum, so advertised == capacity.

Fixes: writes are bounded by dsi_len - doff with doff validated to
{12, 20}; non-write commands keep the strict bound. This restores
full-quantum transfers -- a client may again send (and request) a
complete server_quantum of data per frame, recovering the throughput
the overhead-subtraction workarounds gave up. dsi_writeinit hands the
buffered payload back by reference (NULL drains it) -- one copy, no
truncation, and afp_addicon's error path drains and returns instead of
re-arming the transfer. The client sends header, request block, and
payload in a single writev; speedtest gains an FPWriteExt mode
(default for AFP >= 3.0, -E for legacy) and both tools size transfers
from the full advertised quantum with no overhead subtraction and no
zero-quantum division. dsireadbuf defaults to 32, capped so dsireadbuf
x quantum stays under 1 GiB per session; server quantum values above
the maximum clamp instead of falling back to the default.
dsi_writeinit's signature change bumps the soversion to 21.

Security: the DSI frame-parsing hardening from CVE-2026-62319 and
CVE-2026-62320 (commit cc0a9d1) stays fully in place and is
strengthened -- the write data offset is now validated against an
exact {12, 20} whitelist, so malformed offsets 13-19 are rejected
instead of parsed, and dsi_tcp_open's first-frame whole-frame bound is
unchanged. Those fixes correctly reject a frame whose total exceeds
the quantum; the tools had compensated with frame-overhead
subtraction (PR #3186, PR #3187), which shrank the usable quantum.
This supersedes those workarounds by fixing the server semantics they
compensated for -- restoring full-quantum throughput -- while
preserving their invariant that the client never emits a frame the
hardened parser rejects.

Additionally closes an out-of-bounds read in the FPAddIcon write path:
the old dsi_writeinit truncated the buffered prefix into the caller's
buffer via memmove(buf, start, MIN(buflen, bytes)) but returned the
untruncated byte count, and afp_addicon then wrote that count from a
64 KiB (DSI_DATASIZ) buffer -- reading past the buffer and emitting
stale heap bytes into the icon file whenever a buffered write prefix
exceeded 64 KiB against the 1 MiB default quantum. The pointer handoff
returns the exact buffered range, removing the over-read.

Tests: afpd unit tests pin full-quantum accept (both doffs),
over-quantum reject, doff validation, the strict non-write bound, the
no-duplicate-copy handoff, the alignment search (growth, page
alignment, caps, overflow at the top of the range), and the read-ahead
clamp. RED runs for the two server bugs are preserved in the PR's CI
history. The speedtest dashboard gains sweep-average columns so
small-file regressions are visible alongside peaks.
@sonarqubecloud

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown
Contributor

📊 Performance Dashboard

Commit: ed8dd4c3292b2e7d2319fcfc9cbf3f558f2d976b

🔥 Spectest (AFP 3.4) - FlameGraph

Netatalk Code-time: 2.1% · Runtime: 68s · Stacks: 1143

🔥 Click the preview to open the interactive flamegraph (zoom + search).

Flamegraph preview

🔝 Top 10 leaf functions
Function Samples
_raw_spin_unlock_irqrestore 381477110
do_syscall_64 123483920
__cp_end 89305335
srso_alias_safe_ret 61741960
afpd 44101400
x64_sys_call 42998865
tcp_sendmsg_locked 22050700
do_select 20948165
__tcp_transmit_skb 19845630
__syscall_cp_c 19845630

📈 Speedtest (AFP 3.4) - PerfGraph

Speedtest throughput

🔝 Throughputs per operation
Operation Peak mean (MB/s) avg mean (MB/s) avg max (MB/s)
Read 9315 5206 5851
Write 229 128 381
Copy 3025 1713 1931
ServerCopy 3515 1440 2120

⏱️ Lantest (AFP 3.4) - LatencyGraph

Lantest latency

Avg total runtime: 7373 ms · Avg time per AFP op: 0.116 ms

🐢 All operations (avg runtime, in test order)
Test Avg (ms)
Writing one large file 40
Reading one large file 17
Creating 2000 files 817
Open, write 1024 bytes, close 2000 files 427
Open, read 512 bytes, close 2000 files 372
Copying 2000 files client-side 1507
Copying 2000 files server-side 990
Stat 2000 files 340
Enumerate dir with 2000 files 5
Lock then unlock 2000 open forks 177
Deleting 2000 files 753
Byte-range lock/unlock 2000 ranges in one fork 179
Create directory tree with 1000 dirs 427
Directory cache hits (20 dirs x 100 files) 111
Mixed cache operations (create/stat/enum/delete) on 1000 files 768
Deep path traversal (20 levels x 100 walks) 117
Cache validation (2000 files x 3 lookups) 326

andylemin pushed a commit that referenced this pull request Jul 22, 2026
The decimal KB/MB switch was a workaround for the runt-frame effect of
the pre-fix server, which advertised a quantum just below the
configured power of two so every binary-sized transfer ended in a
near-empty frame. PR #3196 makes the quantum count write data only and
grows it to MSS alignment, so binary sizes no longer trail a runt
frame -- and binary KiB/MiB is what other AFP/SMB testing tools use.

Revert the units in speedtest and lantest (file sizes, the -z sweep,
axis labels, the man page, and the CI/perftest sweep lists) to binary.
Keep the two non-unit changes from that commit: the -d single-size
sentinel fix (SizeFlagSeen) and the shaped-network perftest harness.
@andylemin

andylemin commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

@rdmark Ready for review.

Can be merged in any order as I have already tested using 3203 before and after this - all validated and ready to go

Interesting comparing the speedtests; "Performance BEFORE quantum security hardening", "Performance AFTER quantum security hardening, and the last Performance Dashboard 🙂

@andylemin andylemin changed the title DSI Quantum handling and Speedtest regression fixes DSI Quantum optimisations, alignments and performance regression fixes Jul 22, 2026
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