Milestone 4: ICY titles and narrow metadata updates
On 2026-10-07 the user authorized continuing development after confirming that no dedicated Linux host is available. Milestone 3's local proof is the development baseline; production capacity validation on independent Linux hosts remains deferred. The milestone 3 captures remain historical evidence for their recorded build, not benchmarks of the current ICY build.
Listener wire contract
A single Icy-MetaData: 1 request header enables ICY on GET or advertises its interval on HEAD. Absent headers and other values receive plain encoded bytes; duplicate negotiation headers are rejected as ambiguous. Responses retain normal HTTP/1.0 close-delimited streaming, audio/mpeg and available station headers. ICY-enabled responses add icy-metaint: 16000.
Each listener starts its own count at zero, including retained audio from its join point. After exactly 16,000 audio bytes it receives a one-byte count of 16-byte metadata units, then that many padded bytes. The first interval sends the current title, including StreamTitle=''; for an empty generation. Unchanged title revisions produce a single zero byte. Changed titles use StreamTitle='TITLE'; with NUL padding. There is no invented StreamUrl. Metadata bytes do not contribute to the audio interval count.
The latest generation-local title is sampled at each interval. Intermediate rapid updates may coalesce; this is latest state, not a track-event queue. A late listener may hear retained audio with a current title, differing intentionally from Icecast's historical retained metadata association. Source EOF still immediately closes its generation and listeners; it does not promise draining a final audio or metadata tail.
Metadata endpoint
The public source/listener binding accepts only GET /admin/metadata?mount=...&mode=updinfo&song=... for this route. It also accepts artist plus title, or title alone, when song is absent. An explicitly present song wins, including empty song to clear. Values use form-query + /percent decoding followed by strict UTF-8. No charset conversion occurs; absent charset and explicit UTF-8 are accepted. Unknown fields are ignored within the query bounds; they are not advertised as emitted metadata.
Each mount's existing machine Basic credential authorizes only its own updates. Authentication is checked before accessing active generation state; an authorized inactive mount returns 404. Synchronous admission-lock plus title-lock commit prevents reconnect from redirecting an in-flight update to a different generation. No client-selectable generation ID or asynchronous update task exists. Reconnect initializes the successor to empty title; old listeners and cleanup retain only their original generation.
Successful updates return the narrow text/xml iceresponse envelope containing return=1, following the measured endpoint contract. This fixed response is not general XML administration. Bad queries, duplicate decoded keys, unsupported mode/charset, invalid UTF-8, control characters, semicolons and titles exceeding 1,024 UTF-8 bytes return 400. Missing/wrong credentials return 401 with Basic challenge; unknown/inactive mounts return 404; shutdown returns 503; unexpected internal state failures return 500. Body-bearing metadata requests are rejected before routing. POST and HEAD metadata requests return 405. The private operational binding continues to expose only health and metrics routes.
Semicolons are rejected because they can create additional ICY fields. Apostrophes and backslashes are preserved exactly, matching the original captures; no guessed escaping is introduced. These choices are intentionally stricter than measured Icecast edge cases. Request targets retain the existing 2,048-byte hard bound, so heavily percent-encoded titles can reach that bound before the title limit. Nothing is silently truncated.
Ownership, memory and copying
No task or queue was added. Existing bounded connection tasks own listener bodies and keep their cancellation/join semantics. Source ingest never waits for listener network writes. Metadata admission holds only synchronous locks, never network I/O or .await; title sampling briefly clones an immutable Bytes reference and drops the guard before emitting a body frame.
Audio remains one source-side bounded copy into the ring. A listener holds at most one 16 KiB audio backing allocation; slicing at an ICY boundary shares it. One outstanding body frame waits for transport flush, including metadata frames. Existing cursor monitoring/write deadlines evict stalled ICY listeners just as plain listeners.
A title is at most 1,024 UTF-8 bytes. Its wire block is at most 1,041 bytes, including length byte and padding. Each retained generation owns one current block. Every listener may hold one previous immutable block through Hyper while updates replace the current block. Repeated updates cannot accumulate an unbounded history: at most one block per listener plus one per generation is retained. The block Vec uses its exact wire length; Bytes slicing/cloning retains that whole backing allocation. Empty/unchanged blocks are static.
Startup admission accounting now reserves an additional MAX_BLOCK + 128 bytes for each of the maximum two generations per configured mount, and for every admitted listener. The 128 bytes use the existing conservative allocation/Bytes bookkeeping convention. With G = 2 × configured mounts and L = max listeners, the reservation is:
Temporary updates are bounded by connection admission: the parser owns at most a 2 KiB query, at most 16 decoded key/value pairs with aggregate decoded bytes no larger than the query, a composed bounded title, and one 1,041-byte wire block. This scratch space belongs to the existing bounded connection/request overhead, not the retained-audio reservation. Headers remain bounded at 16 KiB/64 fields. Old-generation title blocks are covered while response-held references survive; the generation permit is released only when its final Arc is gone. Allocator residency, socket buffers, runtime stacks and THP remain separate process/deployment accounting, as documented in milestone 3. Socket-accepted body bytes are split into audio_bytes_served_total and icy_metadata_bytes_served_total; bytes_served_total remains their total, excluding HTTP headers. The producer marks the one outstanding frame through the flush gate, avoiding a second ICY parser in transport. These counters measure socket acceptance, not remote playback. The audio-allocation gauge still measures audio backing allocations only; the new metadata_updates_total counts distinct accepted title changes, not repeated identical requests.
Captured reference evidence
The focused suites 2.4.4 (fixtures--icecast--2.4.4-metadata-edges--provenance.json) and 2.5.0 (fixtures--icecast--2.5.0-metadata-edges--provenance.json) each retain 21 raw dialogues, configuration, exact pinned image/build identity, client versions, harness snapshot, bytes/event ordering, logs and SHA-256 manifest. Both were verified against raw data. See the edge-case matrix (docs--milestone-4-compatibility.json) for per-case evidence paths, classification and Quikcast decisions.
Both releases chose nonempty
songover artist/title in the first combined query. Empty song cleared in 2.4.4; 2.5.0 used Artist - Title. Later 2.5.0 song updates retained the previously supplied artist prefix. Quikcast uses stateless precedence per request.Both copied an embedded apostrophe/semicolon field delimiter into the metadata wire; Quikcast rejects semicolons.
An embedded NUL truncated the observed title; invalid
%FFreached the ICY wire as byte FF. Quikcast rejects controls and invalid UTF-8.Explicit
charset=UTF-8converted the observed accented title to wire byte E9 in both builds/configurations. The original no-charset fixtures preserved UTF-8. Quikcast always preserves valid UTF-8 and does not replicate conversion.Duplicate song fields selected Second in 2.4.4 and First in 2.5.0. Quikcast rejects duplicate decoded fields.
%ZZwas acknowledged without a new observed title during the bounded read. This is not evidence of an eventual update; Quikcast rejects malformed escapes.A 1,024-byte song was accepted and emitted in both suites (2.5.0 additionally retained Artist -). The 4,096-byte query produced no response bytes during the 0.4-second admin read and no replacement title during the subsequent 3-second ICY capture. Classify its rejection/timing as unresolved, not a presumed size ceiling. Quikcast's 1,024-byte limit is an explicit safety contract.
The first final-build client rerun is also retained as failed: Liquidsoap had not reached source admission during the short readiness loop. Its owned containers were removed. The replacement uses a bounded 20-second readiness deadline and retains client logs on failure; this changes test setup timing, not source admission behavior.
The initial 2.4.4 focused attempt is retained separately as incomplete: invalid test bytes caused text log export to raise before provenance/manifest and cleanup. Its owned container was explicitly stopped/removed. The complete rerun uses binary log export; only completed suites support acceptance claims.
Validation and remaining limits
Native tests pass: 19 unit/property tests and 10 loopback integration tests. The final isolated Linux image passed the same 19 + 10 tests, including the ICY stalled peer and distinct audio/metadata socket-byte accounting. Production source hashes are identical to the image's recorded manifest. Tests cover exact audio recovery across multiple metadata boundaries and irregular writes, empty/unchanged/changed titles, UTF-8 quotes/backslashes, artist/title, explicit clearing, 1,024/1,025-byte limits, invalid queries, credential isolation, inactive updates, successor reset, stale cleanup, stopped ICY listener eviction, source independence and joined shutdown. Format, Clippy with warnings denied, CLI curl/SIGTERM smoke and reference/proof Python tests pass.
The current real-client capture (evidence--milestone-4--compatibility-final--provenance.json) passed, and its verification (evidence--milestone-4--verification.json) checks all captured files, current production source identity, both reference suites and all 10 matrix cases. Actual curl players recovered matching MP3 bytes with and without ICY; set/clear captures show correct title blocks with unchanged zero blocks and matching deinterleaved audio. Real curl, FFmpeg and Liquidsoap source audio matched their captured producer bytes and external test decoding succeeded. Liquidsoap metadata queries received 200 and the return=1 envelope. All owned test containers were removed; the four existing RadioPlatform containers remained healthy. The final image and native tests also cover the ICY-enabled stalled peer, HEAD, duplicate negotiation, absent credentials, identical title updates and socket byte totals. The earlier client capture remains separately retained for its exact build; the final capture supersedes it for current source identity. The prior one-million-input fuzz run remains milestone 3 evidence. The updated fuzz target includes the actual ICY/query module, but a new sanitizer campaign is not claimed here. Dedicated-host capacity, broader player releases and optional legacy charsets remain unproven. No AAC/Ogg, MP3 frame parser or HLS feature is introduced in this milestone. The next development milestone is AAC ADTS/Ogg compatibility, preserving narrow parsing and independent HLS lifecycle design.