Concurrency and scheduling
The DeRec library serializes calls on one protocol instance, but owns no cross-instance lock and no timer. Both are the application's, and both have exactly one wrong answer that fails silently. This chapter states what the library guarantees, what it delegates, and the two calls that make a real deployment behave.
Three separate concerns, in the order they bite:
Serialization — which calls may run at the same time.
tick()— how time advances when no message arrives.Timeouts — the four budgets, and why they are four rather than one.
Serialization
Independent partitions need no coordination
Every store is partitioned by secret_id, and the library never issues a cross-partition query. Different secret_ids therefore mean disjoint rows in every table, and their handlers can run on any thread simultaneously with no locking.
This is not incidental: secret_id is fixed at construction, so there is one protocol instance per secret whether the application wants that or not. A service that rebuilds the protocol per request gets this shape for free.
One partition must be serialized
Two responses for the same in-flight round both load-modify-save one accumulator row — the sharing round's tally, or the recovery accumulator. Interleaved, one update is lost and the round never completes.
Calls on one instance are serialized by every SDK:
Rust
process, tick, start, accept, … take &mut self, so two calls cannot share one instance unguarded.
TypeScript
Every state-touching method runs under one async lock per instance. Overlapping calls — a tick() timer firing while process() handles a message — queue and run one at a time, in call order.
React Native
Every protocol call is queued onto the instance's serial native worker thread.
.NET, Go
The native handle holds the protocol behind a mutex; each call locks it for its duration.
Distinct instances do not share that serialization. Two instances bound to the same secret_id and the same stores — in two processes, two request handlers, or one process that builds the protocol per request — are exactly the case that loses updates. The fix is an ordinary per-secret_id lock, held across the call. tick() mutates the same round state an inbound response does, so it belongs inside the same lock as message handling.
Do not call back into the instance from its own callbacks. A store or transport callback runs while the call that invoked it holds the instance's serialization. If the callback awaits another call on the same instance, that call queues behind the one waiting on the callback, and neither settles.
TypeScript — the two calls deadlock silently. Calling
free()while a call is in flight throws.React Native — calling a protocol method from inside a store method's body is detected and rejected with an error instead of hanging. Calls from anywhere else simply queue.
Defer such work until the outer call returns.
A related but weaker hazard: the load-then-remove path during response handling is two round-trips, so a load-balanced deployment may process the same response twice. Every library-emitted event is idempotent from the application's perspective, so a duplicate event corrupts nothing — the on-wire state has already settled.
tick()
Timeouts advance only inside process and tick. An idle protocol with a stranded round never closes it at all: no SharingComplete is ever emitted, and an application waiting on that event waits forever. The same applies to an unacknowledged unpair and to a Pending channel awaiting fingerprint confirmation.
tick() is the entry point that advances time with no inbound message. It does exactly what process does about time, minus the message:
Expired-channel cleanup, if the policy is enabled.
Sharing-round timeouts — every Helper still pending past the window is failed with
ShareRejected, every replica member still pending is reported asReplicaSyncFailed.Unpair-acknowledgement timeouts.
The round tally, which is what turns a fully-drained round into
SharingCompleteand clears its state row. Step 2 only marks the participants; without this the round would report failures and still never close.
It returns the resulting events, and is safe on an idle protocol: with nothing in flight it touches no state and returns an empty list.
Call it on a scheduler — a timer task, a cron tick, a queue heartbeat — at a cadence shorter than the sharing_round timeout, since that bounds how long a stalled round can sit before tick notices it.
A long-lived embedded application can sometimes rely on incidental inbound traffic to carry the sweeps. A service that rebuilds the protocol per request has no background loop at all, so for it this is not optional.
Store failures inside tick are swallowed the same way process swallows them: a transient backend error means the work is retried on the next call rather than surfaced.
Rust
protocol.tick().await
TypeScript
await protocol.tick()
React Native
await protocol.tick()
.NET
await protocol.TickAsync()
Go
protocol.Tick()
Timeouts
Four values, configured together through one setter. Any field left unspecified keeps the library default, in every binding.
inbound_message
300s
Staleness boundary for inbound envelopes — any message older than this is dropped on receipt, whatever the flow, and reported as MessageIgnored with reason Expired. This is the replay-defence window.
sharing_round
60s
How long a publishing round waits for a peer that has not answered. On expiry the silent Helpers are failed, the silent members are reported behind, and the round closes.
unpair_ack
60s
How long to wait for an unpair acknowledgement before dropping the local channel state anyway. Expiry only discards local state; the unpair itself was already sent.
expired_channels
enabled, 300s
Sweep of channels still awaiting out-of-band fingerprint confirmation — every replica pairing, and every NoKeys pairing. Unlike the others, this one can be disabled, leaving the sweep to the application through remove_expired_channels.
Why four and not one
These were one knob until it became clear they answer different questions.
inbound_message is a security boundary: it bounds how stale a message may be and still be accepted. Lower is not safer in any simple sense — the protocol is transport-agnostic, with email among the intended transports, so a store-and-forward hop, a phone that was offline, or a peer whose clock is a minute out all produce legitimately old messages, and this value is what decides whether they are refused.
The other three are liveness budgets: how long to keep hoping a peer will answer before giving up on it.
Collapsing them forced a bad trade. Shortening the round so a stalled publish surfaced sooner also narrowed the window every inbound message is judged against. Any prose that describes "the timeout" as a single protocol-wide value is describing an older library.
expired_channels is a third thing again — a human budget. It bounds how long someone has to compare a fingerprint out of band, possibly reading digits over the phone. Shortening it below a minute or so will strand real pairings; deployments that gate many channels on fingerprints should raise it or disable the automatic sweep and drive cleanup explicitly.
Rust
.with_timeouts(Timeouts { .. })
TypeScript
.withTimeouts({ inbound_message_secs, sharing_round_secs, unpair_ack_secs, expired_channels })
React Native
.withTimeouts({ inbound_message_secs, sharing_round_secs, unpair_ack_secs, expired_channels })
.NET
.WithTimeouts(new Timeouts(InboundMessage: ..., SharingRound: ..., ...))
Go
Config{ Timeouts: &protocol.Timeouts{ InboundMessageSecs: ..., SharingRoundSecs: ..., UnpairAckSecs: ..., ExpiredChannels: ... } }
In Go every *Secs field is a *uint64 and ExpiredChannels a *RemoveExpiredChannelsPolicy; nil means the library default, and any non-nil value, including 0, is forwarded as given. In every binding, one second is the smallest effective unit: wire timestamps carry whole seconds, and a value below one second is clamped to one.
The transport is a mailbox
The third silent-failure surface, covered in full under Implementing a persistent store: replies are posted to the peer's address, not returned from process. A peer that cannot be addressed — a phone, a browser tab, anything behind NAT — never hears back, and nothing reports an error. Such deployments must build the protocol per request with a collecting transport and answer on the inbound connection.
Related
Failure Modes — what a lost round or a restarted process costs.
Implementing a persistent store — the state store is what makes a restart survivable.
Flows — where each timeout applies.
Last updated