Keys pressed on the other phone reached imsd-media as RFC 4733 events and
were dropped with every packet that is not the audio's. The playout now
takes them: the gap before an event's start (a stock sender's DTX silence
often precedes a key press) is filled as before an audio frame, each
packet reports the event's duration so far from its start stamp, and what
the clock has not covered yet is synthesized as the event's Q.23
frequency pair at the packet's volume, so a retransmitted end packet adds
nothing, a lost update is caught up by the next, and a far end whose
audio resumes past the event (the stock phone's does) meets the clock
with the usual fill. An answer may renumber telephone-event from what we
offered (RFC 3264: the answerer sends with the offer's numbers, §6.1,
and the offerer with the answer's, §5.1), so the leg gets both: DTMF_PT
to send with, DTMF_RX_PT to take events at as well — our own SDP's
number: the offer's 98 or 100 on a call we placed, the offer's number we
echoed on one we answered; when the answer gave the audio our offer's
number, that number is the leg's audio and DTMF_RX_PT is the answer's
telephone-event number instead — and takes events at either number,
never at the audio's. --selftest pins the frequency table; the stats
gain rx_dtmf, tones and dtmf_rx_pt.
Tested: --selftest, tests/Sdp and tests/Engine (the eight suites pass);
the loopback's --dtmf-rx arm on a host build of this commit's tree
(journal/ims/captures/2026-10-15-dtmf-loopback-c3-rx.txt; the tx, both,
plain and collision runs beside it); the dev phone's dtmf benches ran in
builds of this series.
SendDtmf was a stub: GNOME Calls' SendTones and Plasma Dialer's sendDtmf
reached imsd and went nowhere. The SDP parser reads the telephone-event
payload type at the chosen codec's clock into the answer, among the
m-line's formats only — an rtpmap the line does not list is not
negotiated, and nothing is sent with a format the answer does not carry
(RFC 3264 §8.3.2); an event's rate is its rtpmap clock (RFC 4733
§2.4.1), an offer with two codec clocks lists telephone-event at each,
and the name is matched as every encoding name is, case-insensitively,
with the clock whole. The media leg carries it, and imsd-media gets it
as DTMF_PT with its stdin on a pipe from imsd, the control pipe. The
SDP answer imsd sends to an inbound offer carries telephone-event by the
same rule, where it echoed any telephone-event rtpmap with a lower-case
name and a clock prefix before: an rtpmap off the offer's m-line is no
longer echoed, an upper-case name now is. The decision is the engine's:
OnDtmf lets the tones through on an active call that negotiated
telephone-event (the answer's number on a call we placed, the offer's on
one we answered) and drops them otherwise with a line that says why:
before the answer, after the call, no telephone-event at the codec's
clock in the SDP, a character that is no tone or more than 128 tones
(then the whole string, never part of what was pressed); letters are
upper-cased. The shell writes one `dtmf <tones>` line per SendDtmf,
whole or not at all: the bound keeps the line within one atomic pipe
write, the write end is non-blocking so that a leg that stopped reading
fails the write instead of holding the engine thread, and a longer line
is refused; SIGPIPE is ignored so that a write after the leg died fails
with EPIPE instead of ending the daemon. The command queue's one
argument is `arg` now — the number to dial, or the tones. The `media …`
start line gains the telephone-event pt at its end.
Tested: tests/Sdp and tests/Engine (the eight suites pass); the dev
phone's dtmf benches ran in builds of this series
(journal/ims/captures/2026-10-14-dtmf-bench-1/ onward).
The keypad during a call (a voicemail menu, an IVR) needs DTMF, and on an
IMS network it travels as RFC 4733 telephone-event packets on the call's
RTP stream. imsd-media reads `dtmf <tones>` lines on its stdin: each
queues the tones (0-9, *, #, A-D), and a tone takes seven 20 ms slots in
place of the audio frames — the event's five (100 ms at -10 dBm0: the
marker bit on the first packet, the duration growing a slot at a time,
the end bit on the fifth packet) and two in which the end packet goes out
again (§2.5.1.4) — then three slots of audio before the next tone. Events
and audio share the stream's sequence and timestamp space (§2.1), and one
sender keeps it: the mic thread hands each captured frame to the DTMF
state, which under one lock sends either the event packet the slot
carries or the frame (the main loop's silence tick does the same without
a mic, and so does the latch burst), so one packet leaves per slot, the
clock stays the capture's, and no frame can land inside an event
whichever thread holds the slot. The payload type comes from DTMF_PT in
the environment; unset means no DTMF and a dtmf line is dropped with a
message; a line of more than 4096 bytes is dropped through its end and
the next line goes. Nothing in imsd supplies the lines or the variable at
this commit; a hand run or the loopback does here, and a hand run behind
a job-control terminal is not stopped by pending input (the reader blocks
SIGTTIN, its read fails and it ends). pw-record no longer inherits stdin.
The stats sidecar gains tx_dtmf and dtmf_pt.
Tested: --selftest, and the fp6 journal's utilities/media_loopback.py
--dtmf arm on a host build of this commit's tree
(journal/ims/captures/2026-10-15-dtmf-loopback-c1-tx.txt; the plain, the
two overflow, the no-mic and the early-tones runs beside it) and its
ttyin.py pty test of the background hand run
(journal/ims/captures/2026-10-16-dtmf-ttyin-hand-run/); the dev phone's
dtmf benches ran in builds of this series.
With the P-CSCF coming from the PDN's PCO, the configuration file has no
required line left, so the apk's ConditionPathExists=/etc/imsd.env drop-in
goes: the unit starts on every phone with the package, and VoLTE is on by
default. Installing the package and inserting a SIM is the opt-in; the file
holds overrides only, and its existence changes nothing. By the versioning
rule (AGENTS §11, the first commit of this series) that is what moves the
major number: the release is 1.0.0, and §11 now records it. What used to be the
file's job — keeping a phone nobody configured out of a failing unit — is now
a rule on facts, the same for every phone: a modem in the failed state with
a SIM reason makes the script write IMSD_IDLE=no-sim to /run/imsd.env and
exit 0; a profile-indexed connect series the network refuses ten times in a
row — each a persistent 3GPP cause in ModemManager's typed error (not
subscribed, unknown APN, barred, not authorised in the PLMN, authentication
failed, the service option not supported, the wrong PDN type) with packet
service at the time; counted only while the modem reads registered,
connecting or connected with packet service not detached — stricter than
the daemon's PacketServiceUp; any other read, a timeout or a transient cause
starts the count again — writes IMSD_IDLE=no-ims-pdn; and a bring-up that
ends with no P-CSCF — none configured, none discovered: no IPv6 P-CSCF read
from the PCO (PCSCF_DISCOVERY=none in the hand-off, the reader's exit 2: the
list absent, empty, unreadable or IPv4/FQDN only), discovery skipped (no
rmnet mux id) or no hand-off at all (a manual run) — and no registration of
imsd's own to resume is the daemon's own idle, decided before it takes the bus name (SaPeer and the
holds' old_gone say what can be resumed; a pair the holds wrote off is taken
out of the kernel there) and at the no-target bring-up, where a pair nothing
can resume is written off and taken out of the kernel first. imsd reads the key, logs idle: … and exits 0, which Restart=on-failure
leaves alone, so the unit stays inactive until the next boot, a systemctl
start or a start of imsd-ofonod where that runs (its Wants starts an idle
imsd again). What is not a fact keeps the retry: no coverage, a SIM still
waiting for its PIN, a modem without an ims profile (the APN-string connect
is tried), an APN-string series (the modem's own wall), a resumable pair
whose attempt failed (the fatal), and a P-CSCF reader that did not answer
(PCSCF_DISCOVERY=failed: the daemon exits 1 and the restart runs discovery
again). A phone that never had the file keeps its stopped unit through the
upgrade and starts at its next boot, registering where the rule above lets
it — at the upgrade itself where imsd-ofonod runs, whose restart pulls imsd
in through its Wants (systemd queues the marked unit's try-restart, collapses
it to a restart and starts what the unit Wants in the same transaction:
job_type_collapse, transaction_add_job_and_dependencies, 262~rc3); the
opt-out is systemctl mask --now imsd, since a plain disable is undone by
that Wants. imsd-ofonod.service loses its After=imsd.service: with imsd
starting on every phone, GNOME Calls' backend would otherwise wait behind
the ims PDN bring-up at every boot and at its marked restart at an upgrade,
for the whole bring-up (up to its TimeoutStartSec=900); it starts at once and republishes when imsd's name
appears (OnImsdAppeared).
The configuration file's path is the IMSD_ENV seam the mock bench uses.
AGENTS §6's lint figures are this tree's (109 findings in 16 of 20 files,
2026-10-13; the 72 of 2026-10-06 predate the Qmi files).
Tested: crafter-build test, 8 passed; on the host build the daemon's idle
exit for the script's keys and, with no pair, for every discovery verdict
but failed (none, skipped, no hand-off) — exit 0 — and the exit 1 for
failed; the script's arms on the mock bench; on the dev phone (KPN), in
builds of this series: the daemon's idle at the no-target bring-up with the
dead pair removed and before the bus name for a pair the holds had written
off, the discovery-failed exit 1 and its restart, the start without the file, the pull-in by an imsd-ofonod restart
and the mask holding against it and a reboot, and imsd-ofonod starting at
once with imsd stopped and republishing when imsd's name appeared.
With the netdev configured, the script runs imsd-pcscf against the
netdev's rmnet mux id (and the ims profile index when it knows one) and
hands the IPv6 addresses it prints to imsd as PCSCF_DISCOVERED= in
/run/imsd.env and gives the first — the daemon's fresh target — a host
route (the SA peer of a registration imsd resumes is routed by imsd itself,
as the configured address's arm already left it); IPv4 addresses and names
are logged and dropped (the PDN is IPv6-only), at most eight are handed
over, and nothing usable is logged with its cause and is not the
script's failure: imsd resumes a registration of its own when it can, else
says so and exits, and the restart runs discovery again. PCSCF= still wins and skips discovery; a list in it is refused.
The script does not read the variable from its environment: the unit
hands it the previous run's file, and a stale discovered value must not
pass for the admin's setting. The reader's path is the IMSD_PCSCF seam
the mock bench uses.
Tested: on the mock bench against this commit's script (the twelve 0.3.10
scenarios; this step's discovery arms S13–S17 and S19–S22, with S18 the
pre-existing non-IPv6 refusal; S55, the reader's rc-0 answer without an
address), and in builds of this series on the dev phone (KPN), whose PCO —
two IPv6 addresses, nothing else — reaches the plain found arm; the
configured skip, the bracketed refusal and, through the IMSD_PCSCF seam, the
reader's failure and its exit 2 ran on the phone too; the v4/FQDN line, the
eight-address cap, the IPv4-only and no-mux-id skips, the comma-list refusal
and the rc-0 arm on the mock only. On a tip build of this series (daemon
2ec1a02f62831edb; bench 40, 2026-10-13) a fresh registration reached a target
this script had not routed — the reader stubbed through the IMSD_PCSCF seam to
find none, the address in /etc/imsd.env as PCSCF_DISCOVERED= — over imsd's own
route, and a resume stood with the peer's route removed first.
imsd reads PCSCF_DISCOVERED= — a comma-separated IPv6 list — from its
environment; nothing in this tree writes it yet. FreshPcscfFromEnv picks
the fresh target: PCSCF= when set — one address, it wins — else the first
entry of the list CanonicalIp6 accepts; the journal says which, and names
the entries it refused. imsd routes the P-CSCF it uses itself — the fresh
target before a fresh registration (BringUp, FreshRecover), the resumed SA
peer once its SAs are confirmed — with the same idempotent `ip -6 route
replace` as a call's media peer: the bring-up script routes only the configured
PCSCF=, so a target taken from PCSCF_DISCOVERED= (set by hand in this tree),
or a route lost with the PDN, had no route. The start line prints that target; a resume still follows the
kernel's SA peer and now says whether that peer is in today's list. With
neither variable and nothing to resume, the fatal names both.
Tested: crafter-build test, 8 passed; the configured, discovered, differing (the
discovered list's order flipped: the resume stays on the SA peer, the next
fresh registration's target is the other address) and empty arms in builds
of this series on the dev phone (KPN).
The network hands the P-CSCF addresses to the modem in the PDN's Protocol
Configuration Options; ModemManager's bearer code does not ask the modem
for them, qmicli (libqmi 1.39) prints none of them, and libqmi has no TLV
for the IPv6 list (0x2E), which is why PCSCF= was a required setting. A
second QMI WDS client reads them once it is bound to the bearer's mux
data port (the binding ModemManager gives its own clients): Bind Mux
Data Port, Set IP Family 6, Get Current Settings, over one AF_QIPCRTR
socket. Imsd:Qmi holds the bytes and the readers (0x22 the flag, 0x23
IPv4, 0x24 FQDN, 0x2E IPv6 in Qualcomm's layout: a count, then 16 bytes
each); imsd-pcscf is the socket, the service lookup and the sequence, and
prints the list, one address per line; nothing runs it yet. It sends
no Start Network and no Stop Network; the bearer stayed connected and the
registration stood after its runs (the fp6 journal's probe capture 04,
benches 38 and 39). The
build, the CI cross-compile, the tarball and the apk gain the product;
linux-headers is named in the sysroot for <linux/qrtr.h>.
Tested: crafter-build test, 8 passed; the aarch64 build of this commit's
helper on the dev phone (KPN) against the live ims bearer.
A resume compared PCSCF= against the kernel's SAs and, when they did not
match, registered fresh without a word — a second initial REGISTER from an
address whose registration is live; on KPN a fresh pair to the same P-CSCF
meets the 21-minute refusal that costs the live registration (§13), what
another P-CSCF does with it is unmeasured. The registration was made to
whatever P-CSCF the SAs name (the SA setup takes imsd's previous pair
out first), so that is where a resume belongs: SaPeer reads
it from the dir-out transport policies — the one address a reqid-1 and a
reqid-2 policy both name, each with imsd's fixed protected port as its
selector's sport (reqid and mode alone are anyone's): the state text cannot tell the UE from the
P-CSCF, a lone policy is not ours and a VPN's beside the pair changes
nothing — TryResume resumes there, routes it, and says so when PCSCF=
differs; the next fresh registration moves to PCSCF= and moves back when
it fails before the SA setup and the old registration still stands (its
address still on the link), as it already did between addresses, and
its fallback Route is the P-CSCF it registers at, set before the 200's
Service-Route is looked for — an empty Service-Route value counts as
none — so a move never keeps the previous node's route. A file left by an attempt that got through the SA setup and did not live
to record — to this or another P-CSCF — is recognised by its SA pair: the
file's spi_uc/spi_us (a 0.3.x file carries them too) against the kernel's
(FileNamesAnotherPair, Imsd:Ipsec, pinned in tests/Ipsec), instead of
resumed with the wrong dialog; the state file gains pcscf, named in that
line. Both fresh paths — the bring-up's and recovery's — now set
their target themselves: with no PCSCF=, a bring-up that did not resume
fails with its own message, and since no fresh one follows: a resumable
pair whose attempt failed stays for the restart to resume, a pair nothing
can resume — one the engine's rule wrote off during the attempt among them,
a refresh refused with a 403 that carries a Retry-After or a binding a NOTIFY
terminated under the re-REGISTER wait, decided once after both catch arms by
its verdict (Reconnectable(), and with no fresh target Fresh(): a 401, a
refusal with a Retry-After, the second refusal — with no fresh target
every refusal reaches the reducer and its count travels in the holds, so
the restart's refusal is the second, while with a target only a
Retry-After does, as before — a refusal without one is not counted and the
fresh registration follows at once; without the count a refused no-target resume would re-REGISTER on the
same Call-ID at every RestartSec, the F16 loop) — is removed so the next
bring-up finds nothing — and a recovery step that needs one restarts the daemon; the "PCSCF not set" check at the top of BringUp is gone, so a
registration can be resumed with no setting at all. Three journal lines of
the fresh-target path change with it: `resume connect failed; falling back
to fresh` and `state file transport differs from SIP_TRANSPORT; fresh` end
in `the next registration is a fresh one`, and a resume the engine writes
off (a 403 with a Retry-After, a binding a NOTIFY terminated under the wait)
logs `resume failed: … — written off by the network: the next registration
is a fresh one` (§7.8). `no reg state file; resume falls back to fresh`
keeps its text with a fresh target and ends in `nothing can resume it: the
pair is removed` without one; the new `the state file names another SA pair
(0x…/0x…[ to X]) than the kernel's (0x…/0x… to Y): another attempt's; …`
line takes the same two tails.
Tested: crafter-build test, 7 passed (tests/Engine: the refusal count's travel
through the holds); the resume on the SA peer with
the fresh target absent in builds of this series on the dev
phone (KPN), and the no-target bring-up there with a resume that failed on
a transport mismatch (the pair kept, the restart resumed) and with the
state file removed (the pair removed, the next bring-up found nothing); the
two dead-resume lines' no-target tail — a missing file, a file with another
pair's SPIs — and the pair's removal on the tip's host build (bench 23f: a
fake modem on PATH, a real xfrm in a namespace; a file with the pair's SPIs
but another P-CSCF's address passes the check and the resume runs — `RESUME
via existing SA`, the connect then failing in the namespace); on the dev
phone (bench 41, the tip's build) a resume on a file without its pcscf key,
the live pair's SPIs.
A fresh registration's SA setup began with `ip xfrm state flush` and `ip
xfrm policy flush`: every SA userspace had installed and every SPD policy
went, a policy-based VPN's (strongSwan's, libreswan's) SAs and policies
included. The setup now takes out what two rules select and leaves the
rest; and the SA reader, which took the P-CSCF ports for a resume from any
reqid-1/2 `dir out` policy — correct only while the flush left imsd's pair
alone in the kernel — takes them from the policies between our two
addresses only. Whether imsd runs beside a live strongSwan or libreswan
connection is unmeasured: the dev phone shows a tunnel policy and a tunnel
state left in place (Tested below), no more.
OwnXfrmDeleteCommands (Imsd:Ipsec) builds the deletes from the kernel text
the shell reads: every transport-mode policy with reqid 1 or 2 whose
selector carries imsd's protected port on the UE side, by the selector ip
xfrm policy prints (src, dst, proto, sport, dport, dev) and its direction,
and every transport-mode reqid-1/2 ESP state running from such a policy's
source to its destination or anchored on the UE's ims-PDN address, by src,
dst, proto esp and SPI — policy or not: a setup whose policy add failed
leaves such states (the setup logs a failed add and goes on), and an ESP
state another transport-mode user put on that address with reqid 1 or 2 goes
with them (an AH or IPComp state is not selected: the rule asks for ESP).
Every reader of the policy text — the deletes and the SA
reader's port pass here — goes through one predicate (detail::OwnPolicy),
written for whatever reads that text later.
BuildSetupCommands loses its two flush lines and the shell runs the deletes
right before it (RunOwnXfrmDeletes); BuildTeardownCommands, the same two
flushes with no caller, goes. A policy-based VPN's tunnel entries and
another transport-mode user's pair on other ports and addresses stay.
Residual: a transport-mode policy with reqid 1 and the UE-side port 45061,
or reqid 2 and 45062 — alone or paired, between any addresses — carries
imsd's fingerprint and goes, with the reqid-1/2 transport-mode ESP states
in its direction, and a reqid-1/2 transport-mode ESP state on the UE's
ims-PDN address goes whoever made it (tests/Ipsec pins both, and that an AH state
stays).
Tested: crafter-build test, 7 passed (the eight deletes byte-exact on the
fixture in the registered FP6's shape (synthetic addresses and keys); a VPN's tunnel SA and policies beside the pair
untouched; another transport-mode user's pair on other ports and addresses
untouched, its reqid-1 ESP state on the UE's address deleted whoever made
it, its AH state left; a policy-less state on the UE's address deleted, one
between other addresses left; the reader's ports from our pair beside
a foreign transport policy with ports); on the dev phone (KPN), in builds of
this series, a fresh registration with a VPN's tunnel policy and state in
the kernel: both still there afterwards, imsd's previous pair gone.
ParseExistingSa and SaLocals compared PCSCF= and LOCAL= as text against
`ip xfrm` output, which prints RFC 5952 form, so an upper-case or
uncompressed PCSCF= found no SAs and the restart registered fresh — on
KPN into the live-SA refusal; the holds file's pdn key compared the same
way. Both sides of each of those compares now go through
imsd::util::CanonicalAddr, LOCAL is canonicalised once on the way in, and PCSCF= is accepted in
that one spelling only — upper case, leading zeros or an uncompressed run
are refused at bring-up with the spelling to use, a dotted-quad tail (the
form ip prints for an IPv4-mapped address, which CanonicalIp6 does not read)
with the rule alone, a bracketed one by the bring-up script, which routes
the value as typed, with the rule (no brackets) — and a comma-separated
PCSCF= (a list) with its own message instead of reaching the socket layer: one spelling,
instead of a leniency both consumers of the setting would have to share.
Tested: crafter-build test, 7 passed; the upper-case PCSCF= refused with the spelling to use, a bracketed one
refused by the script, and the spelling they name resuming, in builds of
this series on the dev phone (KPN).
imsd compares addresses as text against `ip xfrm` output, which prints
RFC 5952 form, while PCSCF= and LOCAL= arrive in whatever spelling the
user typed. CanonicalIp6 produces the §4 form from an IPv6 literal
(brackets accepted; a zone, a port, a dotted-quad tail, a name or an IPv4
address are refused; an IPv4-mapped address spelled in hex comes back in
hex, where inet_ntop writes it dotted), CanonicalAddr applies it to IPv6 only, and
ParseAddressList turns a comma-separated list into canonical,
deduplicated IPv6 literals in order, keeping the entries CanonicalIp6
accepts and naming every entry it refuses — an IPv4 address, a name, an
IPv6 literal with a zone or a dotted-quad tail — to a caller who asks for
them (duplicates and blanks are dropped
silently). Pure std, so the partitions stay free of inet_pton; nothing calls them yet.
Tested: crafter-build test, 7 passed.
0.3.12's rule (§11) said the major number stays 0 and that what 1.0 would
mark is not decided. It is now: the major number moves when a release
changes what an installed phone does without being asked — not with an
ABI change (the ABI is frozen) and not with the size of a release. The
rule comes first so the commits that follow can name the release they are
part of.
0.3.12 is a fix release and stays in 0.3. The patch number counts
releases, and the minor number last moved with emergency calling
(f2a4048, 0.3.0). Nothing in the repository said so; §11 now does, so a
release's number is not re-decided each time.
A warm resume rebuilt its Security-Verify from the kernel's SA: one
mechanism with hmac-sha-1-96 and aes-cbc written in, where RFC 3329
§2.3.1 has the client mirror the server's whole list. The fresh
registration kept the 401's list in memory, so a registration this
process made fresh echoed it; after a restart, the resumed
registration's re-REGISTER, its keepalives, its reg-event SUBSCRIBE and
its INVITEs carried the rebuilt entry until the next fresh registration.
KPN's one-entry answer to the default offer is the rebuilt entry byte
for byte; against a six-entry list (O2's) the mirror is broken, which
the RFC has the server answer with a 494, and a refused resume
registers fresh.
The fresh registration now stores the joined Security-Server value in the
state file (security_server, next to the challenge answer) and the resume
takes it from there, logging the mechanism count, when the entry
carrying its SPIs names the SA pair in the kernel: a file an attempt the
process did not live to record left behind (its SAs installed, its 200
not written) names another. A file from before this change holds none.
In both cases the resume falls back to the rebuilt entry, says which,
and the registration's next state write stores that entry, which is what
it echoes; the choice is Imsd:Ipsec's, pinned in tests/Ipsec with O2's
recorded list and KPN's. A value with a quote, a backslash or a newline in it is
written empty, which the resume treats as absent; RFC 3329's token
syntax produces none of these, a quoted-string extension parameter
could carry the first two.
Tested on the dev phone (KPN) with the build of this tree: a resume from
a state file the 0.3.11 daemon wrote logs the fallback and succeeds, so
do one from a file whose list names another SA pair and one whose list
is empty; two keepalives at a 120-s interval; a fresh registration
stores KPN's list byte-for-byte as the journal shows it received; a
restart resumes with the list from the file; an outgoing call.
The keepalive refresh and the resume's re-REGISTER carried an initial
registration's first-REGISTER Authorization — an empty nonce and
response — in the second REGISTER's frame, with a Security-Client
naming the SPIs and ports in use. TS 24.229 5.1.1.4.2 has a
reregistration carry the Authorization
of the last successful authentication — the last received nonce and
the last calculated response — and a Security-Client with the new
parameter values for the two SA pairs a re-authentication would set
up; TS 33.203 7.4.0 keeps the server ports and changes the client
ports. O2 UK refused the refresh of the old shape with 503 on every
occasion on record (alyx, 2026-09-24 to 10-08, a stationary phone
included) and accepted the fresh registrations that followed; KPN
accepted the old shape without a challenge.
The fresh registration now records the 401's nonce, our cnonce and
the response in the registration state (auth_nonce, auth_cnonce,
auth_response in the state file); a reregistration sends them
verbatim, mints new SPIs and proposes PortUcNext (45063) as the
client port with the server port unchanged. A registration resumed from a
state file written before this change has no answer: its
reregistrations carry the empty digest, as before, until its next
fresh registration, and the resume says so. A 401 to a reregistration asks
for re-authentication over temporary SAs on the proposed ports (TS
24.229 5.1.1.5.1), which imsd does not implement: the refresh throws
it as a refusal and RegRecovery makes the next step the fresh
registration, replacing the in-place answer over the old SAs.
Tested with the Messages and Engine suites and on the dev phone (KPN)
with a build of this commit's tree: a resume from the previous state
file, a fresh registration, a resume that sent the stored answer,
three refreshes at a 120-s interval, an outgoing call to a bench line
and an incoming call's ring.
RegRecovery retried a refused refresh once over the same registration
before escalating, on the reading that one 503 may be a hiccup. A
refusal whose Retry-After is at least a second is the network's
considered answer: on O2 UK (2026-10-08, imsd 0.3.10) the keepalive was
refused 503 with Retry-After 414 and the refresh after that wait 503
with Retry-After 772, so 0.3.10 scheduled the fresh registration no
earlier than 414 + 772 s after the keepalive's refusal. Refusals
without such a Retry-After (none, 0, unparsable) keep the two-refusal
rule, so KPN's bare 403 is unchanged.
Tested with the Engine suite, whose checks of a refusal carrying a
Retry-After now expect the fresh step.
With the modem's radio off (airplane mode) the bring-up waited five
minutes for registration and then tried to connect the ims bearer
anyway. That attempt fails with no-service and leaves ModemManager's
modem state `enabled` while the modem stays in low power; switching the
radio back on then no longer brings the modem up, and the phone has no
mobile service until the modem is disabled and enabled again (dev phone
2026-10-06: not registered 150 s after the radio came on, against 5 s
without the attempt).
0.3.9's bring-up made the same attempts, but it only ran at boot or at
a manual restart. Since this series a registered imsd exits when its
address leaves the netdev and systemd runs the bring-up again, and an
upgrade restarts it: the bring-up now also runs during a radio-off
period.
A modem still disabled after the registration wait, or disabled when a
connect attempt is due, now makes the script exit non-zero without
touching the modem; systemd retries after RestartSec.
Tested on the dev phone (KPN) with this script: a registered imsd, the
radio switched off — imsd exited at its next refresh, the bring-up gave
up after the wait with the modem still disabled, and when the radio came
back the modem registered in 5 s and imsd registered by itself two
minutes later; the same with an upgrade's restart during the radio-off
period. The twelve mock scenarios of the bring-up are unchanged.
lint-rules.h was a project-local copy of the Catcrafts house-style
rules. The rules have their own repository now, and project.cpp imports
them from it (`#pragma crafter dependency`), as Crafter.Build's own
project.cpp does. The dependency is pinned to a commit; crafter-build
clones it whenever it loads project.cpp, so every build fetches it,
CI's included.
The tree does not pass the shared rules yet: `crafter-build lint`
reports 72 findings where the copy reported 20. The shared `naming`
wants camelCase constants and statics (44), `const-local` is new (10),
and `single-declaration` reports two files the copy did not. They are
left for a later change; CI does not run lint.
Tested: build-package.sh run end to end on this commit in a privileged
alpine:edge container (podman) on the workstation, and `crafter-build
lint` on a built copy of this commit.
imsd had two build descriptions to keep in sync: project.cpp for
crafter-build, and a Makefile mirroring it for distros that build from
source, used by packaging/APKBUILD. CI has not used that route since
2026-09-02 — its apks are a crafter-build binary wrapped by
APKBUILD.binary — and the recipe had not followed the releases: its
pkgver was still 0.3.0.
crafter-build is now the only build. AGENTS.md loses the make route
(§4.2) and describes one recipe (§9.1).
Tested: build-package.sh run end to end on this commit in a privileged
alpine:edge container (podman) on the workstation.
The README had been cut down to an introduction and the configuration
variables moved to AGENTS.md, a file for coding agents. A user who has to
set a variable should not need that file.
The README is the user documentation again: installing from the
Catcrafts Alpine repository or the apks, the configuration tables for
imsd and imsd-media, the dialers and the D-Bus ABI. Its build
instructions go: building is in AGENTS.md (§4), with the tests, the
packaging and how the daemon works. AGENTS.md §10 points to the README
for the tables and keeps the two imsd-media pairs that are not
configuration.
Crafter.Build's CI publishes a Flatpak and nothing else since
2026-10-02: the rolling `latest` release's
`crafter-build-linux-x86_64-musl-v2.tar.gz` that build-package.sh
fetched is gone (404 since 2026-10-03), so a push of a release would
fail at `fetch crafter-build` and publish nothing.
Install the Flatpak (`net.catcrafts.CrafterBuild//stable` from
flatpak.catcrafts.net) in the job container and run it as crafter-build.
bubblewrap inside the container needs a privileged runner, so the job
runs on the `flatpak` label. The sysroot stays Alpine edge and gains
llvm-runtimes, the package with the std module source of its own libc++
(libc++-dev ships none). make-sysroot.sh adds its list with --upgrade:
run over a sysroot from before this change (libc++ 22.1.8, no std module
source) a plain add installed llvm-runtimes 23.1.2 alone, and its
std.cppm did not compile against the 22.1.8 headers (`'text_encoding'
file not found`); with --upgrade libc++, libc++-dev and llvm-runtimes
came out at 23.1.2 and it compiled.
AGENTS.md §4.1, §4.3, §8.3 and §9.4 describe the route as it runs, and
the README's Building section carries the install lines. Three comments
in the files this touches said what is not so and are corrected:
package.yml had fp6-img install the package by a pinned version and
sha256 (it takes the newest in the registry), build-package.sh had
fp6-img's image build run `make check` on the aarch64 binaries (it
builds no imsd: no suite runs on them), and make-sysroot.sh's usage line
lacked the --march without which the cross build fails on an x86 host.
Tested: build-package.sh run end to end on this commit in a privileged
alpine:edge container (podman) on the workstation.
The README had grown to 422 lines: the device and carrier table came
after 61 lines of architecture, and the configuration section after 237
lines of architecture, that table, build, run and packaging notes and
GNOME Calls detail.
AGENTS.md, a file for coding agents, takes that detail and adds to it: a
reference to the tree at this commit, written from the code and the
bench record. The README is now what a person needs: what imsd is and
its status, the verified device/carrier table, what it needs, how to
install and enable it, the two dialers, a status check for the installed
daemon, a `Not there yet` list, and where the rest is.
In-tree comments that sent readers to README sections which moved — the
cross-compiling flow (the CI workflow, build-package.sh,
APKBUILD.binary), GNOME Calls' known gaps (imsd-ofono-restart-calls.sh),
the D-Bus ABI (main.cpp) and the Configuration section (imsd.service) —
point at AGENTS.md's sections now.