# Client Keepalive & Idle Timeout Rework — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Make clients send periodic Ping keepalives so the server can distinguish healthy idle subscribers from crashed connections, lowering detection time from ~300s to ~30s.

**Architecture:** Lower the server's default `idle_timeout_secs` from 300 to 30. Add a background keepalive Ping task to `FafbClient` (default every 10s) so authenticated connections stay alive. Add auto-Pong in `recv_frame()` for server-initiated Pings. No server-side logic changes — the existing idle timeout enforcement is unchanged.

**Tech Stack:** Rust, tokio (spawn, sleep, select, Notify), futures (SplitSink/SplitStream)

**Spec:** `docs/superpowers/specs/2026-03-29-client-keepalive-idle-timeout-design.md`

---

### Task 1: Lower idle_timeout_secs default from 300 to 30

**Files:**
- Modify: `src/config.rs:517` (TcpConfig default)
- Modify: `src/config.rs:482` (doc comment)

- [ ] **Step 1: Update the default value**

In `src/config.rs`, in the `Default` impl for `TcpConfig` (line 517), change:

```rust
idle_timeout_secs: 300,
```

to:

```rust
idle_timeout_secs: 30,
```

- [ ] **Step 2: Update the doc comment**

In `src/config.rs` (lines 481-482), change:

```rust
/// Seconds of inactivity before the server closes a connection.
/// 0 means no timeout. Default: 300 (5 minutes).
```

to:

```rust
/// Seconds of inactivity before the server closes a connection.
/// Clients must send periodic Ping frames to prevent timeout.
/// 0 means no timeout. Default: 30.
```

- [ ] **Step 3: Run existing tests to verify nothing breaks**

Run: `cargo test idle_timeout -- --nocapture`

Expected: Both `idle_timeout_closes_connection` and `idle_timeout_reset_by_activity` pass (they set explicit values, not defaults).

- [ ] **Step 4: Run full config tests**

Run: `cargo test config -- --nocapture`

Expected: All pass. The `runtime_config_from_server_config` and `shared_config_matches_runtime` tests will now see `idle_timeout_secs: 30` instead of 300.

- [ ] **Step 5: Fix any config tests that assert the old default**

If any tests compare `idle_timeout_secs` against a hardcoded `300`, update them to `30`.

Run: `cargo test config -- --nocapture`

Expected: All pass.

- [ ] **Step 6: Commit**

```bash
git add src/config.rs
git commit -m "feat: lower idle_timeout_secs default from 300 to 30

Clients must send periodic Ping frames to prevent idle disconnection.
This reduces dead-client detection time from ~5 minutes to ~30 seconds,
enabling faster consumer group rebalancing."
```

---

### Task 2: Add ping_interval to ClientConfig

**Files:**
- Modify: `src/client.rs:106-139` (ClientConfig struct and new() impl)

- [ ] **Step 1: Add the field to ClientConfig**

In `src/client.rs`, add a `ping_interval` field to `ClientConfig` (after `verbose` at line 121):

```rust
/// Keepalive ping interval. The client sends Ping frames at this interval
/// when idle. Set to Duration::ZERO to disable keepalive.
/// Default: 10 seconds.
pub ping_interval: Duration,
```

- [ ] **Step 2: Set the default in ClientConfig::new()**

In `ClientConfig::new()` (line 130), add the field to the constructor:

```rust
ping_interval: Duration::from_secs(10),
```

- [ ] **Step 3: Verify it compiles**

Run: `cargo build 2>&1 | head -30`

Expected: Compilation errors in callers of `ClientConfig::new()` that use struct literal syntax — these will be fixed when we add `..` defaults or update the call sites. If all callers use `ClientConfig::new()`, it should compile clean.

- [ ] **Step 4: Fix any compilation errors**

Search for direct struct construction of `ClientConfig` (not through `new()`):

Run: `grep -rn 'ClientConfig {' src/ tests/`

Add `ping_interval: Duration::from_secs(10)` to any struct literals, or add `..Default::default()` if appropriate.

- [ ] **Step 5: Run tests**

Run: `cargo test -- --nocapture 2>&1 | tail -5`

Expected: All pass.

- [ ] **Step 6: Commit**

```bash
git add src/client.rs
git commit -m "feat: add ping_interval to ClientConfig

Configurable keepalive interval (default 10s). Duration::ZERO disables.
Prepares for background Ping task in FafbClient."
```

---

### Task 3: Add auto-Pong to recv_frame()

**Files:**
- Modify: `src/client.rs:287-299` (recv_frame method)
- Test: `tests/tcp_integration.rs` (new test)

- [ ] **Step 1: Write a unit test for auto-Pong in src/client.rs**

Add a `#[cfg(test)]` module to `src/client.rs` that tests `recv_frame()` intercepts Ping and replies Pong. Since we can't easily inject a Ping from the real server, write a unit test that uses a mock stream or test the behavior via an integration test where the server's idle activity keeps the connection alive.

The most practical approach: write an integration test in `tests/tcp_integration.rs` that verifies `recv_frame()` doesn't return Ping frames to the caller. Use a raw TCP connection to inject a Ping into the client's stream:

```rust
#[tokio::test]
async fn client_auto_pong_on_server_ping() {
    // Bind a raw TCP listener to act as a fake server
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();

    let server_task = tokio::spawn(async move {
        let (stream, _) = listener.accept().await.unwrap();
        let codec = MessageCodec::new(1_048_576);
        let mut framed = Framed::new(stream, codec);

        // Receive Connect, send ConnectAck
        let _connect = framed.next().await.unwrap().unwrap();
        framed.send(Frame::ConnectAck {
            success: true,
            message: String::new(),
            server_capabilities: 0,
        }).await.unwrap();

        // Send Ping — client should auto-reply Pong
        framed.send(Frame::Ping).await.unwrap();

        // Send a Publish frame so the client has a real frame to return
        // Actually, send an Error frame (simpler, doesn't need topic setup)
        framed.send(Frame::Error {
            code: ErrorCode::InternalError,
            message: "test-marker".into(),
        }).await.unwrap();

        // Verify we received Pong
        let pong = framed.next().await.unwrap().unwrap();
        assert!(matches!(pong, Frame::Pong), "expected Pong, got {pong:?}");
    });

    // Connect FafbClient to the fake server
    let mut config = ClientConfig::new(format!("{addr}"), "test-ns", "test-key");
    config.ping_interval = Duration::ZERO; // disable keepalive for this test
    let mut client = FafbClient::connect(&config).await.unwrap();

    // recv_frame() should skip the Ping and return the Error frame
    let frame = client.recv_frame().await.unwrap();
    assert!(matches!(frame, Frame::Error { message, .. } if message == "test-marker"));

    server_task.await.unwrap();
}
```

- [ ] **Step 2: Run the test to verify it fails (no auto-Pong yet)**

Run: `cargo test client_auto_pong_on_server_ping -- --nocapture`

Expected: FAIL — recv_frame() returns the Ping frame instead of skipping it, or the server assertion fails because no Pong was received.

- [ ] **Step 3: Implement auto-Pong in recv_frame()**

In `src/client.rs`, modify `recv_frame()` (around line 287) to loop on Ping:

```rust
/// Receive a single frame. Returns an error if the connection is closed.
///
/// Transparently handles server-initiated Ping frames by replying with
/// Pong and continuing to read the next frame.
pub async fn recv_frame(&mut self) -> Result<Frame, Error> {
    use futures::StreamExt;
    loop {
        let frame = self
            .framed
            .next()
            .await
            .ok_or_else(|| Error::Config("connection closed".into()))?
            .map_err(|e| Error::Config(format!("failed to read frame: {e}")))?;

        if self.verbose {
            log_frame_recv(&frame);
        }

        if matches!(frame, Frame::Ping) {
            // Auto-reply Pong and continue reading
            let _ = self.send_frame(Frame::Pong).await;
            continue;
        }

        return Ok(frame);
    }
}
```

- [ ] **Step 4: Run the test**

Run: `cargo test client_auto_pong_on_server_ping -- --nocapture`

Expected: PASS.

- [ ] **Step 5: Run all tests to check for regressions**

Run: `cargo test -- --nocapture 2>&1 | tail -10`

Expected: All pass.

- [ ] **Step 6: Commit**

```bash
git add src/client.rs tests/tcp_integration.rs
git commit -m "feat: auto-reply Pong to server Ping in FafbClient::recv_frame

recv_frame() now transparently intercepts Ping frames and replies Pong
before continuing to read the next frame. Callers are unaffected."
```

---

### Task 4: Restructure FafbClient with mpsc writer and background keepalive

**Files:**
- Modify: `src/client.rs:150-269` (FafbClient struct, connect, send_frame, recv_frame, close)
- Modify: `src/cli/bench.rs:88,108-118,157,169-179,350` (framed_mut → send_frame/recv_frame)
- Modify: `src/client.rs:351` (publish_no_ack doc comment referencing framed_mut)
- Test: `tests/tcp_integration.rs` (new tests)

This task restructures `FafbClient` to its final form: mpsc channel for writes, dedicated writer loop task, and background keepalive. Tasks 4 and 5 from the original design are merged to avoid building an interim struct that gets ripped out.

- [ ] **Step 1: Identify all callers of framed_mut() and into_framed()**

`framed_mut()` is called in:
- `src/cli/bench.rs:88` (pub bench — high-throughput send loop)
- `src/cli/bench.rs:108-118` (pub bench — ack-drain loop after sends)
- `src/cli/bench.rs:157` (pub bench — concurrent connection send loop)
- `src/cli/bench.rs:169-179` (pub bench — concurrent ack-drain loop)
- `src/cli/bench.rs:350` (sub bench — receive loop)

`into_framed()` has no callers outside `src/client.rs`.

- [ ] **Step 2: Replace the FafbClient struct with the final architecture**

In `src/client.rs`, replace the `FafbClient` struct (line 150):

```rust
pub struct FafbClient {
    /// Channel to send frames — both send_frame() and keepalive task use this.
    frame_tx: mpsc::Sender<Frame>,
    /// Read half of the framed stream.
    reader: SplitStream<Framed<ClientStream, MessageCodec>>,
    capabilities: Capabilities,
    namespace: NamespaceId,
    verbose: bool,
    /// Notified when any frame is sent, to reset the keepalive timer.
    activity: Arc<Notify>,
    /// Background writer loop task.
    writer_handle: tokio::task::JoinHandle<()>,
    /// Background keepalive task (None if disabled).
    keepalive_handle: Option<tokio::task::JoinHandle<()>>,
}
```

Add the free functions above or below the `impl FafbClient` block:

```rust
/// Background task: drains the frame channel and writes to the TCP stream.
async fn writer_loop(
    mut writer: SplitSink<Framed<ClientStream, MessageCodec>, Frame>,
    mut rx: mpsc::Receiver<Frame>,
) {
    while let Some(frame) = rx.recv().await {
        if writer.send(frame).await.is_err() {
            break;
        }
    }
    // Channel closed or write error — shut down cleanly
    let _ = writer.close().await;
}

/// Background task: sends Ping when idle for `interval`.
/// Resets whenever `activity` is notified (i.e., any outbound frame).
async fn keepalive_loop(
    tx: mpsc::Sender<Frame>,
    activity: Arc<Notify>,
    interval: Duration,
) {
    loop {
        tokio::select! {
            () = tokio::time::sleep(interval) => {
                // No activity for `interval` — send Ping
                if tx.send(Frame::Ping).await.is_err() {
                    break; // channel closed
                }
            }
            () = activity.notified() => {
                // Activity: restart the timer.
                // Note: if multiple send_frame() calls happen during one sleep,
                // only one notification is consumed. This is fine — continue
                // restarts the timer from zero regardless.
                continue;
            }
        }
    }
}
```

- [ ] **Step 3: Update connect() to spawn writer loop and keepalive**

After the Connect/ConnectAck handshake succeeds (around line 233):

```rust
let (writer, reader) = framed.split();
let (frame_tx, frame_rx) = mpsc::channel(64);

let writer_handle = tokio::spawn(writer_loop(writer, frame_rx));

let activity = Arc::new(Notify::new());
let keepalive_handle = if config.ping_interval > Duration::ZERO {
    Some(tokio::spawn(keepalive_loop(
        frame_tx.clone(),
        Arc::clone(&activity),
        config.ping_interval,
    )))
} else {
    None
};

Ok(Self {
    frame_tx,
    reader,
    capabilities: negotiated,
    namespace: config.namespace.clone(),
    verbose,
    activity,
    writer_handle,
    keepalive_handle,
})
```

- [ ] **Step 4: Update send_frame() to use the mpsc channel**

```rust
pub async fn send_frame(&mut self, frame: Frame) -> Result<(), Error> {
    if self.verbose {
        log_frame_send(&frame);
    }
    self.activity.notify_one();
    self.frame_tx
        .send(frame)
        .await
        .map_err(|e| Error::Config(format!("failed to send frame: {e}")))
}
```

**Note on error semantics:** `send_frame()` now returns `Ok` when the frame is queued, not when it's written to TCP. Actual I/O errors will surface on the next `recv_frame()` call (reader sees connection close). This is an acceptable tradeoff — the keepalive task needs shared write access, and the mpsc pattern is standard for this.

- [ ] **Step 5: Update recv_frame() to use the reader half**

```rust
pub async fn recv_frame(&mut self) -> Result<Frame, Error> {
    loop {
        let frame = self
            .reader
            .next()
            .await
            .ok_or_else(|| Error::Config("connection closed".into()))?
            .map_err(|e| Error::Config(format!("failed to read frame: {e}")))?;

        if self.verbose {
            log_frame_recv(&frame);
        }

        if matches!(frame, Frame::Ping) {
            // Auto-reply Pong via the writer channel
            let _ = self.frame_tx.send(Frame::Pong).await;
            continue;
        }

        return Ok(frame);
    }
}
```

- [ ] **Step 6: Remove framed_mut() and into_framed()**

Delete both methods (lines 259-269). They can't work with the split/channel architecture.

- [ ] **Step 7: Update publish_no_ack() doc comment**

At line 351, the doc comment says `use framed_mut() directly`. Change to:

```rust
/// If you need maximum throughput for bulk no-ack publishes, use
/// `send_frame()` in a loop without waiting for acks.
```

- [ ] **Step 8: Update close()**

```rust
pub async fn close(self) -> Result<(), Error> {
    if let Some(h) = self.keepalive_handle {
        h.abort();
    }
    drop(self.frame_tx); // closes the channel, writer_loop exits
    // Timeout the writer join in case it's stuck on a blocked write
    let _ = tokio::time::timeout(Duration::from_secs(5), self.writer_handle).await;
    Ok(())
}
```

- [ ] **Step 9: Update bench.rs to stop using framed_mut()**

In `src/cli/bench.rs`, replace all `client.framed_mut()` usages. There are 5 locations:

**Send loops (lines 88, 157):**
```rust
// Before:
let framed = client.framed_mut();
framed.send(frame).await?;

// After:
client.send_frame(frame).await.map_err(CliError::Error)?;
```

**Ack-drain loops after publishing (lines 108-118, 169-179):**
```rust
// Before:
while acks < messages && Instant::now() < drain_deadline {
    match tokio::time::timeout(Duration::from_millis(100), framed.next()).await {
        Ok(Some(Ok(_))) => acks_received += 1,
        ...
    }
}

// After:
while acks < messages && Instant::now() < drain_deadline {
    match tokio::time::timeout(Duration::from_millis(100), client.recv_frame()).await {
        Ok(Ok(_)) => acks_received += 1,
        Ok(Err(_)) => break,
        Err(_) => continue, // timeout, try again
    }
}
```

**Sub bench receive loop (line 350):**
```rust
// Before:
let framed = client.framed_mut();
while let Some(Ok(frame)) = framed.next().await { ... }

// After:
loop {
    match client.recv_frame().await {
        Ok(frame) => { ... }
        Err(_) => break,
    }
}
```

- [ ] **Step 10: Fix build_client_config in src/cli/mod.rs**

The function at `src/cli/mod.rs` (around line 458) constructs `ClientConfig` using struct literal syntax. Add the `ping_interval` field:

```rust
ping_interval: Duration::from_secs(10),
```

- [ ] **Step 11: Verify it compiles**

Run: `cargo build 2>&1 | head -30`

Expected: Clean compile.

- [ ] **Step 12: Run all tests**

Run: `cargo test -- --nocapture 2>&1 | tail -10`

Expected: All pass.

- [ ] **Step 13: Commit**

```bash
git add src/client.rs src/cli/bench.rs src/cli/mod.rs
git commit -m "feat: restructure FafbClient with mpsc writer and background keepalive

Splits Framed stream into read/write halves. Writes go through an mpsc
channel consumed by a dedicated writer loop task. A background keepalive
task sends Ping every ping_interval when idle. recv_frame() auto-replies
Pong to server Pings. Removes framed_mut()/into_framed() — bench.rs
updated to use send_frame()/recv_frame()."
```

---

### Task 5: Add keepalive integration tests

**Files:**
- Test: `tests/tcp_integration.rs` (new tests)

- [ ] **Step 1: Write test — keepalive prevents idle timeout**

Add to `tests/tcp_integration.rs`:

```rust
#[tokio::test]
async fn keepalive_prevents_idle_timeout() {
    let config = TcpConfig {
        idle_timeout_secs: 3,
        ..TcpConfig::default()
    };
    let server = start_test_tcp_server_with_config(
        &[Permission::Subscribe, Permission::Publish],
        config,
    ).await;

    // Connect with a 1-second ping interval
    let mut client_config = ClientConfig::new(&server.addr, "test-ns", "test-key");
    client_config.ping_interval = Duration::from_secs(1);

    let mut client = FafbClient::connect(&client_config).await.unwrap();

    // Publish a message
    client.send_frame(Frame::Publish {
        topic: TopicName("keepalive-test".into()),
        ttl_ms: 0,
        headers: vec![],
        payload: bytes::Bytes::from_static(b"hello"),
    }).await.unwrap();
    let _ack = client.recv_frame().await.unwrap();

    // Subscribe
    client.send_frame(Frame::SubscribeAt {
        topic: TopicName("keepalive-test".into()),
        offset: TopicOffset(0),
        group: None,
    }).await.unwrap();
    let _sub_ack = client.recv_frame().await.unwrap();

    // Receive the message
    let deliver = client.recv_frame().await.unwrap();
    assert!(matches!(deliver, Frame::Deliver { .. }));

    // Wait well past the idle timeout (3s) — keepalive should prevent disconnect
    tokio::time::sleep(Duration::from_secs(8)).await;

    // Connection should still be alive — publish another message
    let result = client.send_frame(Frame::Publish {
        topic: TopicName("keepalive-test".into()),
        ttl_ms: 0,
        headers: vec![],
        payload: bytes::Bytes::from_static(b"still alive"),
    }).await;

    assert!(result.is_ok(), "connection should still be alive after keepalive");

    client.close().await.ok();
}
```

- [ ] **Step 2: Run the test**

Run: `cargo test keepalive_prevents_idle_timeout -- --nocapture`

Expected: PASS.

- [ ] **Step 3: Write test — no keepalive when disabled**

Add to `tests/tcp_integration.rs`:

```rust
#[tokio::test]
async fn no_keepalive_when_disabled() {
    let config = TcpConfig {
        idle_timeout_secs: 2,
        ..TcpConfig::default()
    };
    let server = start_test_tcp_server_with_config(
        &[Permission::Subscribe, Permission::Publish],
        config,
    ).await;

    let mut client_config = ClientConfig::new(&server.addr, "test-ns", "test-key");
    client_config.ping_interval = Duration::ZERO; // disable keepalive

    let mut client = FafbClient::connect(&client_config).await.unwrap();

    // Wait past the idle timeout
    tokio::time::sleep(Duration::from_secs(4)).await;

    // Connection should be dead
    let result = client.send_frame(Frame::Publish {
        topic: TopicName("no-keepalive".into()),
        ttl_ms: 0,
        headers: vec![],
        payload: bytes::Bytes::from_static(b"dead"),
    }).await;

    if result.is_ok() {
        let resp = client.recv_frame().await;
        assert!(resp.is_err() || matches!(resp, Ok(Frame::Error { .. })));
    }
}
```

- [ ] **Step 4: Run the test**

Run: `cargo test no_keepalive_when_disabled -- --nocapture`

Expected: PASS.

- [ ] **Step 5: Write test — keepalive suppressed by traffic**

Add to `tests/tcp_integration.rs`:

```rust
#[tokio::test]
async fn keepalive_suppressed_by_traffic() {
    let config = TcpConfig {
        idle_timeout_secs: 3,
        ..TcpConfig::default()
    };
    let server = start_test_tcp_server_with_config(
        &[Permission::Subscribe, Permission::Publish],
        config,
    ).await;

    // Connect with a 1-second ping interval
    let mut client_config = ClientConfig::new(&server.addr, "test-ns", "test-key");
    client_config.ping_interval = Duration::from_secs(1);

    let mut client = FafbClient::connect(&client_config).await.unwrap();

    // Send Publish frames every 500ms for 5 seconds — faster than ping_interval
    // The keepalive task should NOT send any Pings because traffic resets the timer
    let topic = TopicName("suppress-test".into());
    for _ in 0..10 {
        client.send_frame(Frame::Publish {
            topic: topic.clone(),
            ttl_ms: 0,
            headers: vec![],
            payload: bytes::Bytes::from_static(b"busy"),
        }).await.unwrap();
        let _ack = client.recv_frame().await.unwrap();
        tokio::time::sleep(Duration::from_millis(500)).await;
    }

    // Connection should still be alive (traffic kept it alive, not Pings)
    let result = client.send_frame(Frame::Publish {
        topic: topic.clone(),
        ttl_ms: 0,
        headers: vec![],
        payload: bytes::Bytes::from_static(b"final"),
    }).await;
    assert!(result.is_ok());

    client.close().await.ok();
}
```

- [ ] **Step 6: Run the test**

Run: `cargo test keepalive_suppressed_by_traffic -- --nocapture`

Expected: PASS.

- [ ] **Step 7: Run all tests**

Run: `cargo test -- --nocapture 2>&1 | tail -10`

Expected: All pass.

- [ ] **Step 8: Commit**

```bash
git add tests/tcp_integration.rs
git commit -m "test: add keepalive integration tests

Tests: keepalive prevents idle timeout, disabled keepalive allows timeout,
and keepalive suppressed by active traffic."
```

---

### Task 6: Verify CLI build_client_config compiles

**Files:** None (verification only — `build_client_config` was updated in Task 4, Step 10)

- [ ] **Step 1: Verify build_client_config has ping_interval**

Run: `grep -A5 'ping_interval' src/cli/mod.rs`

Expected: The field is present in the struct literal (added in Task 4, Step 10).

- [ ] **Step 2: Run CLI-related tests**

Run: `cargo test cli -- --nocapture 2>&1 | tail -10`

Expected: All pass.

---

### Task 7: Update config/config.example.toml

**Files:**
- Modify: `config/config.example.toml`

- [ ] **Step 1: Add idle_timeout_secs to the [tcp] section**

After the `slow_consumer_policy` line in `config/config.example.toml`, add:

```toml
# idle_timeout_secs = 30           # FAFB_TCP_IDLE_TIMEOUT — seconds of inactivity before
                                    # closing a connection. Clients must send periodic Ping
                                    # frames (recommended every 10s) to stay connected.
                                    # 0 = disabled. Default: 30.
```

- [ ] **Step 2: Commit**

```bash
git add config/config.example.toml
git commit -m "docs: add idle_timeout_secs to example config with keepalive guidance"
```

---

### Task 8: Update docs/protocol.md — add Keepalive section

**Files:**
- Modify: `docs/protocol.md`

- [ ] **Step 1: Add Keepalive section after the Session Flow**

After the session flow section (around line 867, after the "Alternative auth via Reconnect" note), add:

```markdown
### Keepalive

Clients MUST send `Ping` (0x0C) frames periodically to prevent the server's idle timeout from closing the connection. The recommended interval is every 10 seconds. The server's default idle timeout is 30 seconds.

**Rules:**
- Any frame (not just Ping) resets the server's idle timer
- On busy connections where data flows regularly, explicit Pings are unnecessary
- Ping frames should only be sent when the connection is otherwise idle
- The server may also send Ping frames; clients MUST reply with Pong (0x0D)
- Keepalive applies to all states: Authenticated, Subscribed, and Draining

**Failure to send keepalive Pings on an idle connection will result in disconnection with `Error { InternalError, "idle timeout" }`.**

The `ping_interval` should be at most one-third of the server's `idle_timeout_secs` to allow margin for network jitter and scheduling delays (e.g., 10s ping interval with 30s server timeout).
```

- [ ] **Step 2: Commit**

```bash
git add docs/protocol.md
git commit -m "docs: add Keepalive section to protocol spec"
```

---

### Task 9: Update docs/client_sdk_spec.md

**Files:**
- Modify: `docs/client_sdk_spec.md`

- [ ] **Step 1: Update ping_interval_ms default**

At line 816, change:

```
ping_interval_ms:      number    // keepalive interval (default: 30000)
```

to:

```
ping_interval_ms:      number    // keepalive interval (default: 10000)
```

- [ ] **Step 2: Add Keepalive contract section**

After the TcpOptions block (around line 822), add:

```markdown
#### Keepalive Contract

The server enforces an idle timeout (default 30 seconds). Clients MUST send `Ping` frames at a regular interval when the connection is idle to prevent disconnection. The recommended `ping_interval_ms` is 10000 (10 seconds) — at most one-third of the server's idle timeout.

- Any outbound frame (Publish, Ack, Subscribe, etc.) resets the keepalive timer — only send Ping when idle
- If the server sends a `Ping`, the client MUST reply with `Pong` immediately
- Failure to maintain keepalive results in `Error { InternalError, "idle timeout" }` and connection close
- Keepalive continues during drain (after GoingAway is received)
```

- [ ] **Step 3: Update conformance checklist**

At line 1307, the existing item reads:

```
- [ ] Ping/Pong: respond to server `Ping` with `Pong`, send periodic `Ping`
```

Update to:

```
- [ ] Ping/Pong: respond to server `Ping` with `Pong`
- [ ] Keepalive: send periodic `Ping` at `ping_interval_ms` when connection is idle (MANDATORY for long-lived connections)
```

- [ ] **Step 4: Commit**

```bash
git add docs/client_sdk_spec.md
git commit -m "docs: update client SDK spec with keepalive contract and new defaults

ping_interval_ms default: 30000 -> 10000 to align with 30s server idle timeout.
Adds Keepalive Contract section and conformance checklist updates."
```

---

### Task 10: Update docs/delivery_guarantees.md

**Files:**
- Modify: `docs/delivery_guarantees.md`

- [ ] **Step 1: Update the inflight timeout reference in the comparison table**

At line 15, change:

```
| Redelivery trigger | Inflight timeout (300s default) + exponential backoff |
```

to:

```
| Redelivery trigger | Inflight timeout (300s default) + exponential backoff; dead-client detection via idle timeout (30s default) |
```

- [ ] **Step 2: Update the sequence diagram annotation**

At line 88, change:

```
  |                               |  Timer expires (300s default)
```

to:

```
  |                               |  Timer expires (inflight_timeout_secs, 300s default)
```

(This is the inflight timeout, not the idle timeout — just clarifying which timer.)

- [ ] **Step 3: Update the unclean disconnect row**

At line 311, change:

```
| **Client disconnect (unclean — network drop)** | Server detects via TCP keepalive or idle timeout. Clears subscriptions and inflight. |
```

to:

```
| **Client disconnect (unclean — network drop)** | Server detects via idle timeout (30s default — client keepalive Pings stop arriving). Clears subscriptions and inflight. For consumer groups, inflight messages are released for redelivery to other members. |
```

- [ ] **Step 4: Update the network partition row**

At line 318, change:

```
| **Network partition (client can't reach server)** | Client sees connection timeout/reset. Server sees idle timeout. |
```

to:

```
| **Network partition (client can't reach server)** | Client sees connection timeout/reset. Server sees idle timeout (~30s). |
```

- [ ] **Step 5: Commit**

```bash
git add docs/delivery_guarantees.md
git commit -m "docs: update delivery guarantees with new idle timeout default (30s)

Clarifies dead-client detection via keepalive/idle timeout and
distinguishes idle timeout (30s) from inflight timeout (300s)."
```

---

### Task 11: Update docs/server-tuning.md

**Files:**
- Modify: `docs/server-tuning.md`

- [ ] **Step 1: Add idle_timeout_secs to each tuning profile's [tcp] section**

For the small profile (around line 27) — faster detection, few connections:
```toml
idle_timeout_secs = 15       # fast dead-client detection; clients must send Ping keepalive
```

For the medium profile (around line 113):
```toml
idle_timeout_secs = 30       # clients must send Ping keepalive (recommended every 10s)
```

For the large profile (around line 203) — many connections, reduce Ping traffic:
```toml
idle_timeout_secs = 45       # slightly longer to reduce keepalive overhead at scale; clients ping every 15s
```

- [ ] **Step 2: Commit**

```bash
git add docs/server-tuning.md
git commit -m "docs: add idle_timeout_secs to server tuning profiles"
```

---

### Task 12: Update CLAUDE.md

**Files:**
- Modify: `CLAUDE.md`

- [ ] **Step 1: Add keepalive gotcha to the Gotchas section**

Add to the "Gotchas and Invariants" section:

```markdown
- **`idle_timeout_secs` default is 30, not 300** — clients MUST send periodic Ping frames (recommended every 10s) or the server will close the connection. The `FafbClient` handles this automatically via a background keepalive task. Third-party SDK implementations must implement keepalive with `ping_interval <= idle_timeout / 3`.
```

- [ ] **Step 2: Update Key Design Decisions — Config hot-reload tiers**

Verify `idle_timeout_secs` is listed as Tier 1. It already is — no change needed.

- [ ] **Step 3: Commit**

```bash
git add CLAUDE.md
git commit -m "docs: add keepalive gotcha to CLAUDE.md"
```

---

### Task 13: Integration test — dead client detection and inflight redelivery

**Files:**
- Test: `tests/tcp_integration.rs`

- [ ] **Step 1: Write the dead client redelivery test**

```rust
#[tokio::test]
async fn dead_client_inflight_redelivered_to_group() {
    let config = TcpConfig {
        idle_timeout_secs: 2,
        ..TcpConfig::default()
    };
    let server = start_test_tcp_server_with_config(
        &[Permission::Subscribe, Permission::Publish],
        config,
    ).await;

    let topic = TopicName("redeliver-test".into());
    let group = Some(GroupName("test-group".into()));

    // Publish a message
    let mut publisher = TestClient::connect_and_auth(&server.addr, "test-ns", "test-key").await;
    publisher.publish(&topic, b"important message").await;

    // Consumer A subscribes and receives the message but does NOT ack
    let mut consumer_a = TestClient::connect_and_auth(&server.addr, "test-ns", "test-key").await;
    consumer_a.subscribe_at(&topic, TopicOffset(0), group.clone()).await;
    let _sub_ack = consumer_a.recv().await;
    let deliver = consumer_a.recv().await;
    assert!(matches!(deliver, Frame::Deliver { .. }));
    // Intentionally do NOT send Ack

    // Drop consumer A without clean disconnect — simulates crash
    drop(consumer_a);

    // Wait for idle timeout to detect the dead connection
    tokio::time::sleep(Duration::from_secs(4)).await;

    // Consumer B joins the same group — should receive the redelivered message
    let mut consumer_b = TestClient::connect_and_auth(&server.addr, "test-ns", "test-key").await;
    consumer_b.subscribe_at(&topic, TopicOffset(0), group.clone()).await;
    let _sub_ack = consumer_b.recv().await;

    // Consumer B should receive the message (redelivered after A's disconnect)
    let redelivered = tokio::time::timeout(
        Duration::from_secs(10),
        consumer_b.recv(),
    ).await;

    assert!(redelivered.is_ok(), "consumer B should receive redelivered message");
    assert!(matches!(redelivered.unwrap(), Frame::Deliver { .. }));
}
```

- [ ] **Step 2: Run the test**

Run: `cargo test dead_client_inflight_redelivered_to_group -- --nocapture`

Expected: PASS — consumer A's inflight message is redelivered to consumer B after idle timeout.

- [ ] **Step 3: Commit**

```bash
git add tests/tcp_integration.rs
git commit -m "test: verify dead client inflight messages redelivered to consumer group"
```

---

### Task 14: Run full test suite and clippy

**Files:** None (verification only)

- [ ] **Step 1: Run clippy**

Run: `cargo clippy -- -D warnings 2>&1 | tail -20`

Expected: No warnings.

- [ ] **Step 2: Run fmt check**

Run: `cargo fmt -- --check`

Expected: No formatting issues.

- [ ] **Step 3: Run full test suite**

Run: `cargo test 2>&1 | tail -20`

Expected: All tests pass.

- [ ] **Step 4: Commit any fixes**

If clippy or fmt required changes:

```bash
git add -A
git commit -m "chore: fix clippy warnings and formatting"
```
