Sessions
Session lifecycle and management
Sessions
Reference for OctoMY™'s communication sessions including lifecycle, states, and management.
Pro Tip
Session statistics (
heartbeatRTT,connected,expired) are invaluable for debugging connectivity issues. High RTT often indicates network congestion or NAT timeout issues - consider adjustingFlowControlparameters if you see frequent disconnects.
Session overview
A CommsSession represents an active communication channel between two nodes. Unlike a traditional TCP connection, sessions are tracked optimistically: they record recent communication partners with state such as last seen timestamp, address, cryptographic nonces, and flow control metrics. A session can be in various stages from freshly created through handshake to connected to expired.
Session states
Handshake state machine
The handshake uses an INITIATOR / ADHERENT role model with a three-step SYN / SYN_ACK / ACK exchange. There is no separate "Closed" or "Closing" state -- sessions are created when needed and expire after inactivity.
HandshakeStep enum
| Step | Description |
|---|---|
| SYN | Start handshake (INITIATOR sends, ADHERENT receives) |
| SYN_ACK | Continue handshake (ADHERENT sends, INITIATOR receives) |
| ACK | Complete handshake (INITIATOR sends, ADHERENT receives) |
| NONE | Handshake is done -- session is established |
Session status flags
| Flag | Type | Description |
|---|---|---|
connected |
bool |
Active data exchange occurring (reset on receive, cleared on disconnect timeout) |
expired |
bool |
Session timed out after prolonged inactivity (default 5 minutes) |
established() |
bool |
Handshake complete (handshakeState().isDone()) |
Role assignment
When both sides attempt to initiate simultaneously (both send SYN), an IDDuel resolves the conflict: a deterministic comparison of the two node IDs decides which side becomes INITIATOR and which becomes ADHERENT. The loser deletes its session and creates a new one in the ADHERENT role.
Session lifecycle
Connection establishment
// Session creation (called by CommsChannel / Comms)
// initiator=true for the side that starts the handshake
auto session = comms->createSession(associateId, /*initiator=*/true);
// The handshake state machine progresses automatically:
// INITIATOR: sends SYN -> receives SYN_ACK -> sends ACK -> established
// ADHERENT: receives SYN -> sends SYN_ACK -> receives ACK -> established
// Check completion
if (session->established()) {
// Handshake is done, courier data can flow
}
Active session
// Courier data is sent via the CommsChannel scheduling loop.
// Each Courier registered with the Comms layer gets periodic
// sendingOpportunity() calls when its mandate is active and
// the destination session is established.
// Check connection status
if (session->connected()) {
// Recent data has been exchanged
}
Session expiration
// Sessions do not have an explicit close. They expire:
// - Disconnect timeout (default 3s without send activity)
// sets connected=false
// - Expire timeout (default 5 minutes of inactivity)
// sets expired=true, session is pruned from directory
// Handshake timeout (default 30s) expires incomplete handshakes
if (session->isHandshakeTimedOut(now)) {
// Session will be removed from directory
}
Session properties
Identity
| Property | Type | Description |
|---|---|---|
localSessionID |
SESSION_ID_TYPE (quint16) |
Local side's session ID (randomly generated, unique in directory) |
remoteSessionID |
SESSION_ID_TYPE (quint16) |
Remote side's session ID |
fullID() |
QString |
Associate ID -- 64-char hex SHA256 of the remote node's public key |
address |
QSharedPointer<CarrierAddress> |
Peer's network address |
Security
| Property | Type | Description |
|---|---|---|
sessionKey |
QByteArray |
32-byte key derived during handshake for AEAD encryption |
mSessionKeyReady |
bool |
Whether sessionKey has been derived |
mTxNonceCounter |
quint64 |
Monotonically increasing counter for outgoing AEAD nonces |
mRxNonceHighWatermark |
quint64 |
Highest received nonce counter for replay detection |
mRxNonceWindow |
quint64 |
64-bit sliding window bitmap for out-of-order replay detection |
mKeyMaterial |
QByteArray |
32-byte random material for key-transport path (RSA / cross-algorithm) |
Handshake nonces
| Property | Type | Description |
|---|---|---|
mOurSynNonce |
SESSION_NONCE_TYPE (quint32) |
Nonce generated for our SYN |
mOurSynAckNonce |
SESSION_NONCE_TYPE (quint32) |
Nonce generated for our SYN_ACK |
mOurAckNonce |
SESSION_NONCE_TYPE (quint32) |
Nonce generated for our ACK |
mTheirLastNonce |
SESSION_NONCE_TYPE (quint32) |
Last nonce received from peer |
Timing and flow
| Property | Type | Description |
|---|---|---|
mHeartbeatRTT |
quint64 |
Round-trip time measured via SessionCourier heartbeat echo (ms) |
mLastSendTime |
qint64 |
Timestamp of last packet sent |
mLastReceiveTime |
qint64 |
Timestamp of last packet received |
mDisconnectTimeout |
qreal |
Seconds without send before connected goes false (default 3.0) |
mExpireTimeout |
quint64 |
Milliseconds of inactivity before session expires (default 300000) |
Session directory
CommsSessionDirectory
Manages all active sessions. Sessions are indexed by session ID, full ID (associate ID), and network address.
class CommsSessionDirectory : public QObject {
public:
// Session lookup
QSharedPointer<CommsSession> bySessionID(SESSION_ID_TYPE id) const;
QSharedPointer<CommsSession> byFullID(const QString& id) const;
QSharedPointer<CommsSession> byAddress(const QSharedPointer<CarrierAddress>& addr) const;
// Session sets
QSet<QSharedPointer<CommsSession>> all() const;
QSet<QSharedPointer<CommsSession>> byActiveTime(quint64 lastActiveTime) const;
QSet<QSharedPointer<CommsSession>> byIdleTime(quint64 lastActiveTime) const;
// Session management
void insert(const QSharedPointer<CommsSession>& session);
void remove(const QSharedPointer<CommsSession>& session);
SESSION_ID_TYPE generateUnusedSessionID();
int count();
bool hasHandshakers();
int countHandshakers();
quint32 prune(); // Remove expired sessions
signals:
void clientAdded(QSharedPointer<CommsSession>);
};
Session lookup
// Find session by associate ID (64-char hex SHA256)
auto session = directory->byFullID("A3F1...");
// Find session by local session ID
auto session = directory->bySessionID(sessionID);
// Iterate all sessions
for (const auto& session : directory->all()) {
qDebug() << session->signatureToString();
}
Handshake details
Handshake state machine
Handshake packet format
All three handshake packets share a common outer structure. The first two bytes are the multimagic (packet type discriminator). The remainder is encrypted with the recipient's public key.
SYN (INITIATOR to ADHERENT):
Encrypted with recipient's public key:
| Field | Size | Description |
|---|---|---|
senderID |
32 bytes | SHA256 of sender's public key (raw binary) |
destinationID |
32 bytes | SHA256 of intended recipient's public key (prevents cross-nonce pollution) |
desiredSessionID |
2 bytes | SESSION_ID_TYPE -- our local session ID |
nonce |
4 bytes | SESSION_NONCE_TYPE -- random SYN nonce |
keyMaterial |
32 bytes | Random bytes for session key derivation (only when encryption enabled) |
SYN_ACK (ADHERENT to INITIATOR):
Encrypted with recipient's public key:
| Field | Size | Description |
|---|---|---|
senderID |
32 bytes | SHA256 of sender's public key (raw binary) |
desiredSessionID |
2 bytes | SESSION_ID_TYPE -- our local session ID |
returnNonce |
4 bytes | Echo of the INITIATOR's SYN nonce (proves we received it) |
ourNonce |
4 bytes | SESSION_NONCE_TYPE -- fresh SYN_ACK nonce |
keyMaterialHash |
32 bytes | SHA256(keyMaterial) -- confirms receipt of key material (only when encryption enabled) |
ACK (INITIATOR to ADHERENT):
Encrypted with recipient's public key:
| Field | Size | Description |
|---|---|---|
senderID |
32 bytes | SHA256 of sender's public key (raw binary) |
returnNonce |
4 bytes | Echo of the ADHERENT's SYN_ACK nonce (proves we received it) |
Key derivation
After the three-way handshake completes, both sides derive the session key. There are two paths depending on the key algorithms in use:
Ed25519 <-> Ed25519 (ECDH path):
// Convert Ed25519 keys to X25519 for Diffie-Hellman
auto myX25519Secret = Ecc::ed25519SecretToX25519(localKey->rawSecretKeyBytes());
auto theirX25519Public = Ecc::ed25519PublicToX25519(remoteKey->rawPublicKeyBytes());
QByteArray sharedSecret = Ecc::x25519KeyExchange(myX25519Secret, theirX25519Public);
// Derive session key: SHA256(sharedSecret || synNonce || synAckNonce)
QCryptographicHash hash(QCryptographicHash::Sha256);
hash.addData(sharedSecret);
hash.addData(synNonceBytes); // big-endian, 4 bytes
hash.addData(synAckNonceBytes); // big-endian, 4 bytes
sessionKey = hash.result(); // 32 bytes
RSA or cross-algorithm (key-transport path):
// The INITIATOR generates 32 random bytes (keyMaterial) and
// sends them in the SYN, encrypted with the recipient's public key.
// The ADHERENT confirms receipt by echoing SHA256(keyMaterial) in SYN_ACK.
// Derive session key: SHA256(keyMaterial || synNonce || synAckNonce)
QCryptographicHash hash(QCryptographicHash::Sha256);
hash.addData(keyMaterial); // 32 bytes from SYN
hash.addData(synNonceBytes); // big-endian, 4 bytes
hash.addData(synAckNonceBytes); // big-endian, 4 bytes
sessionKey = hash.result(); // 32 bytes
In both paths, the nonce roles are determined by the session role: the INITIATOR uses ourSynNonce as synNonce and the ADHERENT's nonce as synAckNonce, and vice versa.
Reliability and flow control
Design
Per-packet reliability headers were removed from the session-level protocol. Instead:
ReliabilitySystemis a utility class available to individual couriers that need reliable delivery. It maintains sequence numbers, ack bitmasks, and pending/acked packet queues.- RTT is measured via
SessionCourierheartbeat echo timestamps and stored inmHeartbeatRTT. FlowControladjusts the send rate based on the heartbeat RTT, using a Good/Bad mode with penalty-based backoff.
ReliabilitySystem
class ReliabilitySystem {
public:
void packetSent(qint32 size);
void packetReceived(quint32 sequence, qint32 size);
quint32 generateAckBits();
void processAck(quint32 ack, quint32 ack_bits);
void update(float deltaTime);
// Retransmission support
void setRetransmissionCallback(RetransmissionCallback callback);
void checkForRetransmissions();
// Statistics
quint32 sentPackets() const;
quint32 receivedPackets() const;
quint32 lostPackets() const;
float sentBandwidth() const;
float ackedBandwidth() const;
float roundTripTime() const;
};
Couriers that need reliability use this system via session->reliabilitySystem(). Couriers that do not need reliability (e.g., status updates, sensor data) simply send best-effort and let stale data be overwritten by newer packets.
FlowControl
class FlowControl {
public:
enum Mode { Good, Bad };
void reset();
void update(quint64 deltaTime, quint64 rtt);
quint64 sendRate() const;
};
FlowControl is updated each send cycle with the heartbeat RTT. It switches between Good and Bad modes: when RTT exceeds thresholds, the mode degrades and the send rate is reduced. A penalty timer prevents rapid mode oscillation.
Session events
Key signals (from CommsChannel)
class CommsChannel : public QObject {
signals:
void sessionConnected(const QString& fullID); // Handshake completed
void sessionExpired(const QString& fullID); // Session timed out and was removed
void handshakePacket(bool received); // Handshake packet sent or received
void dataPacket(bool received); // Data packet sent or received
void packetActivity(); // Any packet activity
void commsError(const QString& message); // Protocol error
void trustNacked(const QString& senderID); // Explicit trust rejection
};
Session timeouts
Timeout configuration
| Parameter | Default | Description |
|---|---|---|
| Handshake timeout | 30s | Max time for incomplete handshake before session is removed |
| Disconnect timeout | 3s | Time without send activity before connected goes false |
| Expire timeout | 5 min | Inactivity period before session is marked expired and pruned |
| Handshake cooldown | 60s | Delay before retrying handshake with a failed associate |
| Handshake retry backoff | 500ms-8s | Exponential backoff for handshake retransmission |
Timeout handling
// In rescheduleSending(), each tick:
// 1. Check for handshake timeouts and abandonment
for (const auto& session : sessions().all()) {
if (!session->established()) {
bool timedOut = session->isHandshakeTimedOut(now);
bool abandoned = session->handshakeState().shouldAbandon(now);
if (timedOut || abandoned) {
sessions().remove(session);
// 60s cooldown before retrying
}
}
}
// 2. Disconnect detection happens in countSend():
// If disconnectTimeoutAccumulator > disconnectTimeout (3s),
// connected is set to false.
// 3. Expire detection also in countSend():
// If expireTimeoutAccumulator > expireTimeout (5 min),
// expired is set to true, and session is pruned on next prune() call.
Session security
AEAD encryption
All established sessions with encryption enabled use XChaCha20-Poly1305 AEAD (via oc_crypt::Ecc::aeadEncrypt / aeadDecrypt).
Sending (in sendCourierData)
// 1. Write session ID as multimagic (2 bytes, unencrypted)
state.writeMultimagic(session->remoteSessionID());
// 2. Write nonce counter (8 bytes, unencrypted -- needed by receiver)
quint64 nonceCounter = session->nextTxNonce(); // monotonically increasing
*state.stream << nonceCounter;
// 3. Build plaintext: [courierID:4][payloadSize:2][payload...]
QByteArray plaintext;
// ... courier writes its data ...
// 4. Build 24-byte AEAD nonce: [counter:8 big-endian][zeros:16]
QByteArray aeadNonce(24, 0);
qToBigEndian(nonceCounter, aeadNonce.data());
// 5. AEAD encrypt
QByteArray ciphertext = Ecc::aeadEncrypt(plaintext, session->sessionKey(), aeadNonce);
// 6. Write ciphertext (includes Poly1305 auth tag)
state.stream->writeRawData(ciphertext.constData(), ciphertext.size());
Receiving (in recieveData)
// 1. Session ID already read as multimagic
// 2. Read nonce counter
quint64 nonceCounter;
*state.stream >> nonceCounter;
// 3. Replay detection (sliding window)
if (!session->checkAndUpdateRxNonce(nonceCounter)) {
return; // Replay or too-old nonce
}
// 4. Read remaining bytes as ciphertext
QByteArray ciphertext(state.totalAvailable, 0);
state.stream->readRawData(ciphertext.data(), state.totalAvailable);
// 5. Build 24-byte AEAD nonce from counter
QByteArray aeadNonce(24, 0);
qToBigEndian(nonceCounter, aeadNonce.data());
// 6. AEAD decrypt (returns empty on auth failure)
QByteArray plaintext = Ecc::aeadDecrypt(ciphertext, session->sessionKey(), aeadNonce);
Wire format summary
Data packet (encrypted):
[sessionID:2][nonceCounter:8][AEAD ciphertext + Poly1305 tag]
Data packet (unencrypted):
[sessionID:2][courierID:4][payloadSize:2][payload...]
Replay detection
The sliding window mechanism in checkAndUpdateRxNonce accepts nonces that are:
- Higher than the current high watermark (advances the window), or
- Within 64 of the high watermark and not yet seen (checked via bitmask)
Nonces more than 64 behind the watermark or already seen are rejected.
Session key lifetime
Session keys are derived once at handshake completion and are not rotated during the session lifetime. A new handshake (new session) produces a new key. Key material is securely wiped from memory when the session is destroyed.
Multimagic dispatch
The first two bytes of every packet are the multimagic field (SESSION_ID_TYPE = quint16). Reserved values below MULTIMAGIC_LAST (currently 9) identify protocol-level packet types. Values at or above FIRST_STATE_ID (= MULTIMAGIC_LAST) are interpreted as session IDs for courier data dispatch.
| Multimagic value | Meaning |
|---|---|
| 0 | MULTIMAGIC_IDLE -- keepalive/discovery |
| 1 | MULTIMAGIC_SYN -- handshake initiation |
| 2 | MULTIMAGIC_SYNACK -- handshake response |
| 3 | MULTIMAGIC_ACK -- handshake completion |
| 4-7 | LAN discovery protocol |
| 8 | MULTIMAGIC_NACK -- explicit trust rejection |
| >= 9 | Session ID -- courier data for that session |
Multiple sessions
Session per peer
Each remote associate has at most one session. The CommsSessionDirectory indexes sessions by full ID (associate ID), session ID, and address.
Debugging sessions
Session status
void debugSession(CommsSession* session) {
qDebug() << "Signature:" << session->signatureToString();
qDebug() << "Full ID:" << session->fullID().left(16);
qDebug() << "Local session ID:" << session->localSessionID();
qDebug() << "Remote session ID:" << session->remoteSessionID();
qDebug() << "Address:" << (session->address() ? session->address()->toString() : "NULL");
qDebug() << "Established:" << session->established();
qDebug() << "Connected:" << session->connected();
qDebug() << "Expired:" << session->expired();
qDebug() << "Heartbeat RTT:" << session->heartbeatRTT() << "ms";
qDebug() << "Session key ready:" << session->sessionKeyReady();
qDebug() << "Handshake:" << session->handshakeState().toString();
}
Common issues
| Issue | Cause | Solution |
|---|---|---|
| Stuck in handshake | Firewall blocking UDP | Open the UDP port used by the carrier |
| Frequent disconnects | NAT timeout < 3s | Reduce disconnect timeout or increase keepalive frequency |
| Handshake restarts (duel) | Both sides initiate simultaneously | Normal behavior -- IDDuel resolves it automatically |
| AEAD decryption failures | Key mismatch or corrupted packet | Re-pair nodes (forces new handshake and key derivation) |
| Session expired prematurely | Expire timeout too short | Increase mExpireTimeout (default 5 minutes) |
| NACK received | Remote does not trust this node | Check that nodes are properly paired in their address books |