Environment
- Server (smp-server): Docker image
simplexchat/smp-server:v6.5.0, built from simplexmq
commit ba6af65 (tag v6.5.0, 2026-04-29).
- Client (simplex-chat core library):
v6.5.6, extracted from the signed
simplex-aarch64.apk release (arm64-v8a libsimplex.so/libsupport.so). This version of
simplex-chat pins its own simplexmq dependency at commit 92598c2 via cabal.project
(stable branch as of 2026-06-21, not itself a tagged release).
- Version skew, disclosed because it may be relevant to the bug: the client's simplexmq
dependency (92598c2, 2026-06-21) is 16 commits and about two months newer than the server's
(ba6af65, 2026-04-29). Reviewed the titles of those 16 commits — none reference subscription
or agent-connection handling by name, but this was a commit-message review, not a full diff
audit, so a protocol/agent-state mismatch between server and client versions is not ruled out
as a contributing factor.
- Client harness: a minimal third-party Android app driving the core directly via C FFI.
Verified against the v6.5.6 source (apps/multiplatform/common/src/commonMain/cpp/android/ simplex-api.c) that the four functions used —chatMigrateInit, chatSendCmdRetry,
chatRecvMsgWait, chatCloseStore — are exactly the same functions and signatures the
official app itself calls (only the JNI package qualifier differs, since this harness uses its
own package rather than chat.simplex.common.platform). Same command/response protocol
throughout (bots/api/COMMANDS.md, bots/api/TYPES.md), not a different or reduced API surface.
- Server config: self-hosted, default
[INACTIVE_CLIENTS] (ttl=21600,
check_interval=3600), store_messages = memory, restore_messages = on, store_queues = memory, expire_messages_days = 21. Server and client on the same local network (Android
emulator via the 10.0.2.2 host alias).
Summary
If a client process is killed uncleanly (no chatCloseStore() call) immediately after receiving
a message, the SMP acknowledgment for that message is never sent. On every subsequent clean
start, the client's subscription to that specific queue is stuck in pendingSubscriptions (per
/get subs details) and never transitions to activeSubscriptions on its own — confirmed idle
for at least 60 minutes with no self-resolution. The subscription only completes when unrelated
new traffic arrives at the same queue (confirmed to be the same queue throughout, by watching a
fixed rcvId transition live — not a distinct queue picking up traffic while an abandoned one
leaks separately), at which point it activates and delivers the entire backlog at once, including
the message from the original unclean shutdown. The same scenario with a clean shutdown
instead of an unclean kill does not reproduce the bug — the subscription completes normally within
seconds either way.
Severity/triage note: in every reproduction here, the message that triggers the stuck state
was already delivered to and durably stored in the recipient's local database before the process
was killed, so it is not lost or invisible to the recipient — the confirmed impact is a
relay-side bookkeeping issue (an indefinitely unacknowledged message occupying a queue slot,
bounded by the 128-message-per-queue quota and 21-day message expiry), not confirmed data loss.
A more severe variant — a kill landing during receipt, before the local database write commits,
which would leave the recipient genuinely unaware a message was even sent — was tested for
statistically (80 randomized-kill cycles) and not observed; see "What we tested" below for why
that's a bound and not a proof.
This is not an edge case for a mobile client: Android routinely kills app processes (low memory,
force-stop, backgrounding) without running any graceful-shutdown callback, so "unclean kill right
after receiving a message" is a normal operating condition, not a rare failure.
Reproduction steps
- Two users,
alice and bob, each with their own ChatCtrl (chatMigrateInit +
/_create user ... + /_start), both configured (/_servers) against the same self-hosted
SMP relay.
- Establish a contact connection between them (
/_connect invite from alice, /_connect plan
/_connect accept from bob) and confirm both sides reach contactConnected.
- Stop bob's process cleanly (
chatCloseStore + process exit — connection itself is healthy at
this point).
- With bob's process not running, alice sends a text message (
/_send @<bobContactId> ...).
Confirmed server-side: the message is queued (relay log on restart:
messages stored: 1, expired: 0, queues: 2).
- Start bob's process fresh (
chatMigrateInit on the same existing on-disk database +
/_start). The message is delivered normally — a newChatItems event fires with the correct
content, i.e. it is now durably stored in bob's local database.
- Do not call
chatCloseStore(). Kill bob's process immediately after step 5's
newChatItems event (in our repro: adb shell am force-stop, i.e. SIGKILL-equivalent, no
graceful shutdown hook runs).
- Restart the relay and check its log: the message is still present
(messages stored: 1, expired: 0, queues: 2) — confirms the acknowledgment was never sent
before the kill in step 6.
- Start bob's process again (same on-disk database,
chatMigrateInit + /_start). The message
is not redelivered, and /get subs details shows the queue's rcvId permanently in
pendingSubscriptions, never activeSubscriptions:
{"result":{"type":"agentSubsDetails","agentSubs":{
"activeSubscriptions":[],
"pendingSubscriptions":[{"userId":1,"server":"smp://<fp>@<host>","rcvId":"XsuyWw_UHI0YCDYvDZih1nRY3ub9m6pW"}],
"removedSubscriptions":[]
}}}
This persists across repeated fresh-process attempts, and for at least 60 minutes of idle time
with zero client or network activity in between.
- Sending a new, unrelated message from alice to the same contact resolves it: the same
rcvId transitions to activeSubscriptions, and both the new message and the original
stuck one are delivered together. Confirmed server-side too — the relay's message count drops
to 0 at that point.
What we tested
Ruled out: INACTIVE_CLIENTS server-side timeout. Hypothesized the server might be holding
the queue "claimed" by bob's dead TCP session until its own inactivity timeout expired. Tested
directly: reproduced the same scenario against a separate relay instance with [INACTIVE_CLIENTS]
ttl=8 / check_interval=5 (vs. the default ttl=21600 / check_interval=3600), confirmed via
the relay's own startup log that the faster settings were active (expiring clients inactive for 8 seconds every 5 seconds), and waited well past several sweep cycles (20+ seconds, then again
90+ seconds). The message stayed stuck on that relay too. This rules out the inactive-client
sweep as the mechanism.
No self-resolution after 60 minutes idle. Left the stuck state completely untouched (no
traffic, no client activity of any kind) for a full hour, then reopened bob. Identical result to
the shorter tests (events_on_reopen=0) — not a delay that eventually resolves on its own.
Specific to unclean shutdown. Reproduced the same scenario but with a genuinely clean
chatCloseStore() before bob goes offline (instead of an unclean kill), then sent a new message
while he's closed. The reopened subscription shows the same brief pendingSubscriptions state
immediately after /_start (normal startup lag), then transitions to activeSubscriptions on
its own within the observation window and delivers normally. A clean shutdown does not reproduce
the stuck-forever state.
Same queue confirmed via live tracking, not inferred. With the subscription already stuck,
watched /get subs details repeatedly across a send of a new message from alice: the same
rcvId was in pendingSubscriptions immediately before and after the send, then present in
activeSubscriptions at the exact snapshot where the new message's newChatItems event fired.
The relay's message count went to 0 at that same point (both messages delivered and acked
together).
Recovery commands tested — neither is a reliable fix. /_resubscribe all
(ResubscribeAllConnections) flips the reported status from pending to active but does not
flush the backlogged message — relay still reports it queued afterward, no delivery within a
20-second window. /reconnect (ReconnectAllServers) had no effect at all — no status change,
no delivery.
Fault-injection test: statistical bound on the more severe "kill mid-receive" variant. The
window between the server delivering a message and the agent's local database write committing
is inside the Haskell core and not deterministically injectable from the client side, so this was
tested statistically: sent a numbered 100-message sequence, then ran 80 cycles of starting bob's
process, letting it receive for a randomized delay (0.5–6 seconds), and externally killing it
(adb shell am force-stop) at that point — never calling chatCloseStore. Aggregated every
message received across all 80 cycles and compared against the sent sequence.
Result, stated precisely: zero gaps and zero duplicates across 80 kill cycles, at a sequence
length (100) kept safely under the relay's 128-message-per-queue quota, after correcting an
initial harness artifact. That artifact: the first attempt used 200 messages sent in one burst
before bob ever started receiving, which silently exceeded the 128-message quota — messages
130–200 were rejected at send time and never entered the queue at all. This initially looked like
a 71-message gap and would have been a dramatic (and wrong) result; caught by checking the
relay's own message count rather than trusting the client-side tally alone, then corrected by
re-running under-quota. Noted here deliberately, since anyone else attempting to reproduce at a
higher message count should watch for the same failure mode.
This is a statistical bound, not a debugger-verified proof: 80 external, randomly-timed kills
against a fast local relay is a real result, not a single clean pass, but it does not establish
that the exact byte-level window between the server sending a message and the local database
write committing is unreachable or safe. A negative result here means "not observed in 80
trials," not "impossible."
Impact
- Not head-of-line blocking. A new message sent while an old one sits stuck is delivered to
the recipient normally and promptly — confirmed both client-side and via the relay's message
count (leaked message stays put, new message's slot clears independently).
- Confirmed impact is relay-side, not recipient-visible, in every scenario reproduced here: a
queue slot occupied indefinitely by an unacknowledged message, bounded in practice by the
128-message quota (defaultMsgQueueQuota in simplexmq, a compile-time constant not
configurable via smp-server.ini) and 21-day message expiry.
- Asymmetric failure mode when it does surface: a sender whose send hits a full queue gets an
immediate, visible QUOTA error; a recipient with a stuck-but-already-delivered message has no
client-visible indication their queue has an outstanding ack, and neither side can clear it
from the client (see recovery commands above).
- The more severe variant — genuine recipient-side message loss from a kill during the local
database write — remains unconfirmed. 80 fault-injection cycles targeting this found no gaps
or duplicates, a meaningful bound, not proof it cannot happen.
Prior art
Searched simplex-chat/simplexmq and simplex-chat/simplex-chat issues (open and closed) and
GitHub Discussions across a wide range of phrasings (pending subscription, resubscribe,
ResubscribeAllConnections, chatCloseStore, unclean shutdown, force stop, messages stuck, queue quota, Android-lifecycle-specific terms, and more) — no existing report of this
behavior found. One issue reviewed in detail and ruled out as a different mechanism:
simplex-chat/simplex-chat#6487
("Messages not received on groups") describes group membership/read-receipt propagation
reliability across many members, not a single 1:1 queue's subscription getting stuck after one
client's unclean shutdown.
Not covered by this report
- Exact root cause of why the subscription doesn't self-complete on a bare reopen is not
established — this report describes reproducible behavior and what's been ruled out
(INACTIVE_CLIENTS, delayed self-resolution, clean-shutdown reproduction), not a diagnosed
cause in the agent's subscription-handling code.
- The precise byte-level kill-mid-commit window was not deterministically reproduced (not
possible from outside the Haskell core with the tools available here) — the fault-injection
test above is a statistical bound over 80 trials, not an exhaustive test or a debugger-verified
negative result.
Environment
simplexchat/smp-server:v6.5.0, built from simplexmqcommit
ba6af65(tagv6.5.0, 2026-04-29).v6.5.6, extracted from the signedsimplex-aarch64.apkrelease (arm64-v8alibsimplex.so/libsupport.so). This version ofsimplex-chat pins its own simplexmq dependency at commit
92598c2viacabal.project(
stablebranch as of 2026-06-21, not itself a tagged release).dependency (
92598c2, 2026-06-21) is 16 commits and about two months newer than the server's(
ba6af65, 2026-04-29). Reviewed the titles of those 16 commits — none reference subscriptionor agent-connection handling by name, but this was a commit-message review, not a full diff
audit, so a protocol/agent-state mismatch between server and client versions is not ruled out
as a contributing factor.
Verified against the
v6.5.6source (apps/multiplatform/common/src/commonMain/cpp/android/ simplex-api.c) that the four functions used —chatMigrateInit,chatSendCmdRetry,chatRecvMsgWait,chatCloseStore— are exactly the same functions and signatures theofficial app itself calls (only the JNI package qualifier differs, since this harness uses its
own package rather than
chat.simplex.common.platform). Same command/response protocolthroughout (
bots/api/COMMANDS.md,bots/api/TYPES.md), not a different or reduced API surface.[INACTIVE_CLIENTS](ttl=21600,check_interval=3600),store_messages = memory,restore_messages = on,store_queues = memory,expire_messages_days = 21. Server and client on the same local network (Androidemulator via the
10.0.2.2host alias).Summary
If a client process is killed uncleanly (no
chatCloseStore()call) immediately after receivinga message, the SMP acknowledgment for that message is never sent. On every subsequent clean
start, the client's subscription to that specific queue is stuck in
pendingSubscriptions(per/get subs details) and never transitions toactiveSubscriptionson its own — confirmed idlefor at least 60 minutes with no self-resolution. The subscription only completes when unrelated
new traffic arrives at the same queue (confirmed to be the same queue throughout, by watching a
fixed
rcvIdtransition live — not a distinct queue picking up traffic while an abandoned oneleaks separately), at which point it activates and delivers the entire backlog at once, including
the message from the original unclean shutdown. The same scenario with a clean shutdown
instead of an unclean kill does not reproduce the bug — the subscription completes normally within
seconds either way.
Severity/triage note: in every reproduction here, the message that triggers the stuck state
was already delivered to and durably stored in the recipient's local database before the process
was killed, so it is not lost or invisible to the recipient — the confirmed impact is a
relay-side bookkeeping issue (an indefinitely unacknowledged message occupying a queue slot,
bounded by the 128-message-per-queue quota and 21-day message expiry), not confirmed data loss.
A more severe variant — a kill landing during receipt, before the local database write commits,
which would leave the recipient genuinely unaware a message was even sent — was tested for
statistically (80 randomized-kill cycles) and not observed; see "What we tested" below for why
that's a bound and not a proof.
This is not an edge case for a mobile client: Android routinely kills app processes (low memory,
force-stop, backgrounding) without running any graceful-shutdown callback, so "unclean kill right
after receiving a message" is a normal operating condition, not a rare failure.
Reproduction steps
aliceandbob, each with their ownChatCtrl(chatMigrateInit+/_create user ...+/_start), both configured (/_servers) against the same self-hostedSMP relay.
/_connectinvite from alice,/_connect plan/_connectaccept from bob) and confirm both sides reachcontactConnected.chatCloseStore+ process exit — connection itself is healthy atthis point).
/_send @<bobContactId> ...).Confirmed server-side: the message is queued (relay log on restart:
messages stored: 1, expired: 0, queues: 2).chatMigrateIniton the same existing on-disk database +/_start). The message is delivered normally — anewChatItemsevent fires with the correctcontent, i.e. it is now durably stored in bob's local database.
chatCloseStore(). Kill bob's process immediately after step 5'snewChatItemsevent (in our repro:adb shell am force-stop, i.e.SIGKILL-equivalent, nograceful shutdown hook runs).
(
messages stored: 1, expired: 0, queues: 2) — confirms the acknowledgment was never sentbefore the kill in step 6.
chatMigrateInit+/_start). The messageis not redelivered, and
/get subs detailsshows the queue'srcvIdpermanently inpendingSubscriptions, neveractiveSubscriptions:{"result":{"type":"agentSubsDetails","agentSubs":{ "activeSubscriptions":[], "pendingSubscriptions":[{"userId":1,"server":"smp://<fp>@<host>","rcvId":"XsuyWw_UHI0YCDYvDZih1nRY3ub9m6pW"}], "removedSubscriptions":[] }}}with zero client or network activity in between.
rcvIdtransitions toactiveSubscriptions, and both the new message and the originalstuck one are delivered together. Confirmed server-side too — the relay's message count drops
to 0 at that point.
What we tested
Ruled out:
INACTIVE_CLIENTSserver-side timeout. Hypothesized the server might be holdingthe queue "claimed" by bob's dead TCP session until its own inactivity timeout expired. Tested
directly: reproduced the same scenario against a separate relay instance with
[INACTIVE_CLIENTS]ttl=8/check_interval=5(vs. the defaultttl=21600/check_interval=3600), confirmed viathe relay's own startup log that the faster settings were active (
expiring clients inactive for 8 seconds every 5 seconds), and waited well past several sweep cycles (20+ seconds, then again90+ seconds). The message stayed stuck on that relay too. This rules out the inactive-client
sweep as the mechanism.
No self-resolution after 60 minutes idle. Left the stuck state completely untouched (no
traffic, no client activity of any kind) for a full hour, then reopened bob. Identical result to
the shorter tests (
events_on_reopen=0) — not a delay that eventually resolves on its own.Specific to unclean shutdown. Reproduced the same scenario but with a genuinely clean
chatCloseStore()before bob goes offline (instead of an unclean kill), then sent a new messagewhile he's closed. The reopened subscription shows the same brief
pendingSubscriptionsstateimmediately after
/_start(normal startup lag), then transitions toactiveSubscriptionsonits own within the observation window and delivers normally. A clean shutdown does not reproduce
the stuck-forever state.
Same queue confirmed via live tracking, not inferred. With the subscription already stuck,
watched
/get subs detailsrepeatedly across a send of a new message from alice: the samercvIdwas inpendingSubscriptionsimmediately before and after the send, then present inactiveSubscriptionsat the exact snapshot where the new message'snewChatItemsevent fired.The relay's message count went to 0 at that same point (both messages delivered and acked
together).
Recovery commands tested — neither is a reliable fix.
/_resubscribe all(
ResubscribeAllConnections) flips the reported status from pending to active but does notflush the backlogged message — relay still reports it queued afterward, no delivery within a
20-second window.
/reconnect(ReconnectAllServers) had no effect at all — no status change,no delivery.
Fault-injection test: statistical bound on the more severe "kill mid-receive" variant. The
window between the server delivering a message and the agent's local database write committing
is inside the Haskell core and not deterministically injectable from the client side, so this was
tested statistically: sent a numbered 100-message sequence, then ran 80 cycles of starting bob's
process, letting it receive for a randomized delay (0.5–6 seconds), and externally killing it
(
adb shell am force-stop) at that point — never callingchatCloseStore. Aggregated everymessage received across all 80 cycles and compared against the sent sequence.
Result, stated precisely: zero gaps and zero duplicates across 80 kill cycles, at a sequence
length (100) kept safely under the relay's 128-message-per-queue quota, after correcting an
initial harness artifact. That artifact: the first attempt used 200 messages sent in one burst
before bob ever started receiving, which silently exceeded the 128-message quota — messages
130–200 were rejected at send time and never entered the queue at all. This initially looked like
a 71-message gap and would have been a dramatic (and wrong) result; caught by checking the
relay's own message count rather than trusting the client-side tally alone, then corrected by
re-running under-quota. Noted here deliberately, since anyone else attempting to reproduce at a
higher message count should watch for the same failure mode.
This is a statistical bound, not a debugger-verified proof: 80 external, randomly-timed kills
against a fast local relay is a real result, not a single clean pass, but it does not establish
that the exact byte-level window between the server sending a message and the local database
write committing is unreachable or safe. A negative result here means "not observed in 80
trials," not "impossible."
Impact
the recipient normally and promptly — confirmed both client-side and via the relay's message
count (leaked message stays put, new message's slot clears independently).
queue slot occupied indefinitely by an unacknowledged message, bounded in practice by the
128-message quota (
defaultMsgQueueQuotainsimplexmq, a compile-time constant notconfigurable via
smp-server.ini) and 21-day message expiry.immediate, visible
QUOTAerror; a recipient with a stuck-but-already-delivered message has noclient-visible indication their queue has an outstanding ack, and neither side can clear it
from the client (see recovery commands above).
database write — remains unconfirmed. 80 fault-injection cycles targeting this found no gaps
or duplicates, a meaningful bound, not proof it cannot happen.
Prior art
Searched
simplex-chat/simplexmqandsimplex-chat/simplex-chatissues (open and closed) andGitHub Discussions across a wide range of phrasings (
pending subscription,resubscribe,ResubscribeAllConnections,chatCloseStore,unclean shutdown,force stop,messages stuck,queue quota, Android-lifecycle-specific terms, and more) — no existing report of thisbehavior found. One issue reviewed in detail and ruled out as a different mechanism:
simplex-chat/simplex-chat#6487("Messages not received on groups") describes group membership/read-receipt propagation
reliability across many members, not a single 1:1 queue's subscription getting stuck after one
client's unclean shutdown.
Not covered by this report
established — this report describes reproducible behavior and what's been ruled out
(
INACTIVE_CLIENTS, delayed self-resolution, clean-shutdown reproduction), not a diagnosedcause in the agent's subscription-handling code.
possible from outside the Haskell core with the tools available here) — the fault-injection
test above is a statistical bound over 80 trials, not an exhaustive test or a debugger-verified
negative result.