For the complete documentation index, see llms.txt. This page is also available as Markdown.

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:

  1. Serialization — which calls may run at the same time.

  2. tick() — how time advances when no message arrives.

  3. 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:

SDK
How one instance is serialized

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.

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:

  1. Expired-channel cleanup, if the policy is enabled.

  2. Sharing-round timeouts — every Helper still pending past the window is failed with ShareRejected, every replica member still pending is reported as ReplicaSyncFailed.

  3. Unpair-acknowledgement timeouts.

  4. The round tally, which is what turns a fully-drained round into SharingComplete and 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.

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.

process settles deadlines first, then handles the message. If the message then fails, the deadline events it already produced — sharing-round and unpair timeouts — are saved and are not reported again, so they travel on the error. Handle them exactly like a successful call's events, then handle the error. The error also names the channel the message arrived on (absent when the bytes were not a decodable envelope).

SDK
Events
Channel

Rust

ProcessError::events

ProcessError::channel_id

TypeScript, React Native

DeRecError.events

DeRecError.channel_id

.NET

DeRecException.Events

DeRecException.ChannelId

Go

(*protocol.ProcessError).Events

(*protocol.ProcessError).ChannelID

See When process fails.

SDK
Call

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.

Field
Default
Governs

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.

SDK
Setter

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.

Last updated