{"record":{"id":"3ab707f9d999d3d7","repo":"zeroclaw-labs/zeroclaw","slug":"wecom-ws-command-response-channel-closed-before","errorCode":null,"errorMessage":"WeCom WS {command} response channel closed before ack (req_id={req_id})","messagePattern":"WeCom WS (.+?) response channel closed before ack \\(req_id=(.+?)\\)","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"crates/zeroclaw-channels/src/wecom_ws.rs","lineNumber":410,"sourceCode":"        &self,\n        frame: Value,\n        req_id: &str,\n        command: &str,\n    ) -> Result<()> {\n        let (tx, rx) = tokio::sync::oneshot::channel();\n        self.pending_responses\n            .lock()\n            .await\n            .insert(req_id.to_string(), tx);\n\n        if let Err(err) = self.ws_send_frame(frame).await {\n            self.pending_responses.lock().await.remove(req_id);\n            return Err(err);\n        }\n\n        match tokio::time::timeout(Duration::from_secs(WECOM_COMMAND_TIMEOUT_SECS), rx).await {\n            Ok(Ok(result)) => result,\n            Ok(Err(_)) => anyhow::bail!(\n                \"WeCom WS {command} response channel closed before ack (req_id={req_id})\"\n            ),\n            Err(_) => {\n                self.pending_responses.lock().await.remove(req_id);\n                anyhow::bail!(\n                    \"WeCom WS {command} ack timeout after {}s (req_id={req_id})\",\n                    WECOM_COMMAND_TIMEOUT_SECS\n                );\n            }\n        }\n    }\n\n    async fn maybe_handle_command_response(&self, frame: &Value) -> bool {\n        let Some(req_id) = frame\n            .get(\"headers\")\n            .and_then(|headers| headers.get(\"req_id\"))\n            .and_then(Value::as_str)\n        else {","sourceCodeStart":392,"sourceCodeEnd":428,"githubUrl":"https://github.com/zeroclaw-labs/zeroclaw/blob/88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc/crates/zeroclaw-channels/src/wecom_ws.rs#L392-L428","documentation":"Commands sent over the WeCom WS embed a req_id and register a oneshot response channel in pending_responses. If the sender half of that channel is dropped before the ack arrives — connection task shutting down, entry removed on reconnect, or a req_id collision — the receiver errors with RecvError and this bail fires, naming the command and req_id.","triggerScenarios":"ws_send_respond_msg or send_markdown_chunks_to_scope in flight exactly when the WS connection shuts down or reconnects; the reader task clears pending_responses while a command awaits its ack; duplicate req_id registration evicting a live waiter.","commonSituations":"Reconnect racing an outbound command; deliberate channel shutdown with commands still awaiting acks.","solutions":["Retry the command — a fresh req_id is generated and re-registered on the (new) connection","During deliberate shutdown, cancel/ignore in-flight commands instead of treating the closed channel as an error","If it repeats outside reconnects, grep logs for the req_id to check for id collisions or premature cleanup"],"exampleFix":null,"handlingStrategy":"retry","validationCode":null,"typeGuard":null,"tryCatchPattern":"Catch the 'response channel closed before ack' bail and re-issue the command (fresh req_id) after confirming the connection is up; suppress and cancel in-flight commands during deliberate shutdown instead of propagating.","preventionTips":["Cancel in-flight command futures on reconnect and re-issue them once the session is back","Serialize WS commands per connection to avoid req_id races","Log req_id correlation so collisions and premature cleanups are visible"],"tags":["wecom","websocket","ack","channel","race","shutdown"],"backgroundTag":"response-channel-closed","analyzedSha":"88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc","analyzedAt":"2026-08-23T01:07:41.857Z","schemaVersion":2},"datasetVersion":"2026-08-23T08:06:27.607Z"}