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.
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).
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.
imsd took the first global IPv6 on the ims netdev as its address, and
the kernel lists the temporary (privacy) SLAAC address first: `temporary
dynamic`, preferred for a day and valid for two by its lifetimes (read
2026-09-24). Contact, Via and the four SAs were bound to it, so once the
kernel removes it the registration has lost its address. The address
ModemManager assigned — the one the script installs — sat third on the
list. Seen on the dev phone; a field unit's journal shows the split too
— imsd registered on an address other than the one the script installed
(ims ledger F18).
Hand the assigned address to the daemon as LOCAL through /run/imsd.env,
next to DEV. For a manual run without it, prefer a global address that
is neither `temporary` nor `dynamic`, then any non-temporary one.
A restart after the upgrade finds the registration's SAs on the old
address. It resumes there while that address is still on the link, and
moves to the PDN's address in place: with its next fresh registration,
or when the old address leaves the link first. When the address it is
registered on leaves the netdev and the PDN's own address is not there
to move to, a registered imsd exits at its next recovery step, non-zero,
for a systemd restart, which runs the PDN bring-up again.
Tested on the dev phone (KPN) in builds of this series; not every path
has run there.
The profile-list query answered while ModemManager is still enabling the
modem can be partial: entries, but no ims yet. The lookup took that as
"no ims profile", fell back to the APN-string request — the one a walled
carrier refuses — and never looked again (dev phone cold boot 2026-09-24
18:18:59: "none for apn ims" at `enabling`, the ims profile listed when
read minutes later (18:19–18:25); harmless on KPN, a refused boot on O2
UK). The 0.3.8 fix covered only an empty list.
Treat a list without the APN as final only when the modem is registered
(or already connecting/connected); before that, retry it after the
registration wait like a failed lookup. A retry that still has no final
answer logs the modem state.
Tested on the dev phone (KPN) in builds of this series; not every path
has run there.
The PGW's router advertisement on the ims netdev offers a default
router, and on IPv4-only wifi that is the phone's only IPv6 default
route. Every AAAA-first client (apk, browsers) then sends its SYN into
the IMS walled garden, which drops it: apk sat in SYN-SENT for nine
minutes on the dev phone, and a field user's remedy was to stop imsd
before package work.
Set accept_ra_defrtr=0 on the muxed netdev before link-up (the router
solicitation goes out once the link-local address passes DAD), flush a
default route an earlier RA installed (an imsd restart), and give the
P-CSCF a host route over the netdev instead. imsd talks to the P-CSCF
only and already installs a host route per media peer; the RA's prefix
and MTU still apply. The host route makes the P-CSCF reachable before
the RA has been processed, so the PDN MTU ModemManager reports is set
on the link as well, the way NetworkManager does for its bearers. A
failed host route, or a P-CSCF that is not an IPv6 address (the PDN is
brought up IPv6-only), fails the unit with a clear message.
Dev phone (KPN, 0.3.8-r0): restart path drops the RA default and
registers over the host route, apk update in 6 s where it hung; MO and
MT calls with audio both ways; cold boot never installs the default
route, the link carries the 1280 MTU before the host route exists,
fresh registration in 1 s.
On a cold boot the profile-list query can succeed while ModemManager is
still enabling the modem and return no profiles at all; the lookup then
read "no ims profile" and fell back to the APN-string request, the one a
walled carrier refuses (Fairphone 6, index 2 present a minute later).
Treat a list without any profile entry like a failed lookup and retry it
after the registration wait; a list with entries but no ims APN still
falls back to the APN string.
The ims profile lookup ran the moment ModemManager listed the modem, which
on a cold boot can be while it is still enabling; qmicli's error was
discarded, so a failed lookup read as "no profile" and the script fell back
to the APN-string request, which is the one the modem refuses under an
IPv4 attach. Every refused attempt left a disconnected apn=ims bearer
object behind, and the next restart's "reuse a stale disconnected bearer"
step matched it by APN and reconnected it: ten refusals again, no way out
but a reboot that happens to win the race (O2 UK field report; the same
lost race seen on a KPN unit, where the fallback merely connects).
Log qmicli's error and retry the lookup after the registration wait, fall
back to the APN string only when the list has no such APN, reuse a
disconnected bearer only when it has this run's shape and delete leftover
APN-string ims bearers when connecting by profile index, match the APN
case-insensitively, and on call-already-present adopt the connected bearer
instead of asking the modem again.
Ten field shapes replayed against a mock ModemManager (shipped script
5/10, this one 10/10); on the FP6 a warm restart adopts the live PDN and
a cold boot connects by profile index and registers.
On carriers whose attach PDN is granted IPv4-only (Odido, Swisscom, O2 UK
so far) the modem refuses every AP-side IPv6 bearer request made by APN
string with pdn-ipv6-call-disallowed before it reaches the air, so the
ims PDN never came up and imsd looped. A request made by 3GPP profile
index is treated as the IMS-class call the modem's own engine makes and
is exempt: it goes on the air as IPv6 and the network grants it. That is
the path 81voltd uses.
Look the ims profile up by APN in the modem's profile list and create the
bearer with profile-id=<index>,ip-type=<type>; fall back to the APN string
when no such profile exists. A profile-indexed bearer reports no APN, so
the reuse match accepts the profile id too. New knobs in /etc/imsd.env:
IMS_APN (default ims) and IMS_PROFILE_ID (default: looked up; "none"
forces the APN-string path).
Verified on KPN with the wall induced by a forced IPv4 attach: the
APN-string request is refused, the profile-indexed request connects, a
second run reuses the bearer, imsd registers over it; attach and mobile
data untouched.
Three hardenings, each of which cost a field-debugging round-trip:
- Every mmcli failure now lands in the journal verbatim (create-bearer
and connect stderr were swallowed, making 'connect attempt N failed'
undecodable — no-service and interface-in-use looked identical).
- Connect attempts are gated on network registration (up to
IMS_REG_TIMEOUT, default 300 s): the 10x10 s window is shorter than
some carriers' post-boot attach (~2 min measured), so every attempt
could burn out before the network was even attached. The settled
modem/packet-service state is logged either way.
- Bearer ip-type is configurable via IMS_IP_TYPE in /etc/imsd.env
(default ipv6); the bearer find/reuse now matches ip-type too, so a
config change cannot silently reuse a stale bearer of the old type.
TimeoutStartSec grows 600->900 to cover the registration gate.
Tested on the FP6 dev phone (KPN): clean-state bring-up in 4 s;
gate + timeout path with modem disabled; per-attempt error lines on a
real contested-PDN failure (second ims PDN while one is connected);
idempotent reuse + REGISTERED (fresh) via systemd.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Userspace VoLTE/IMS daemon for mainline Linux phones (developed on the
Fairphone 6): a GLib-free core (SIP, SDP, USIM AKA, IPsec SA setup, RTP
media, call engine) behind a GDBus control daemon, a standalone AMR-WB
media leg, and a Plasma Dialer backend. C++26 modules built with Crafter
Build; the tree is clean under the project's house-style linter
(crafter-build lint) across all three build products.
Assisted-by: Claude:claude-fable-5
Signed-off-by: Jorijn van der Graaf <jorijnvdgraaf@catcrafts.net>