在一次 OpenHarmony 设备联调中,我们为约 2KB 的物模型响应增加了 CoAP 分块传输,并修复了重组后的业务回调问题。单次查询能够完成,但随后出现了一个回归:中枢控制子设备,开始几次正常,多操作几次就可能立即失败,过一会儿又恢复。

故障最终定位到客户端的资源保护逻辑。为避免并发请求覆盖分块状态,代码把请求快照保存在整个 endpoint 共享的位置,并在该位置被占用时拒绝新请求。结果,一条普通上报只要还在等待回复,就可能让同一 endpoint 上的控制请求返回 REQ_LIST_FULL。

修复把请求快照移到各自的请求节点,保留真正分块传输的互斥保护。2026 年 10 月 26 日晚的三端回归中,11 次控制全部完成,2230 字节物模型完整到达配置器,未再出现此前观察到的 -327656 阻塞错误。

问题是什么:上报还在等回复,控制却发不出去了

正常情况下,中枢向配置器发送一条上报后,即使还没收到确认,也应当能继续向子设备发送普通控制请求。两条请求有各自的 token 和业务回调,只要请求表还有容量,就不应因为共享同一个 endpoint 而互相阻塞。

实际出现的情况是:先有一条上报或心跳在等待回复,此时配置器再下发开关控制,中枢转发立即失败。终端没有收到这条控制,自然也不会执行。等待前一条请求结束,再次操作又可能成功。

观察点预期行为故障时的行为
一条普通请求正在等待回复保留它的请求记录,允许另一条普通请求发送共享快照被占用,另一条请求被判为“忙”
配置器控制子设备中枢发包,终端执行,再返回结果中枢发送接口直接返回 -327656,没有发出控制包
连续操作每条请求分别等待自己的结果取决于是否碰上其他请求的等待窗口,时好时坏
前一条普通响应到达后结束对应请求共享快照释放,后续操作又能发送

这里排查的是“发送前被拒绝”的回归,与此前“物模型分块收齐后丢失业务回调、查询停在 100”是两个问题。物模型分块功能已经可以工作,并不代表普通控制与上报交错时也一定正常。

为什么物模型分块会影响普通控制

测试环境由 WS63 应用终端、Hi3516CV610 中枢和 Dayu200 配置器组成。配置器向中枢发送查询或控制,中枢再向子设备发起请求。中枢在这两段通信中分别承担服务端和客户端角色,因此故障落在 CoAP 客户端代码,并不意味着出问题的是配置器应用。

本文所说的“DTLS 分块”,具体是 DTLS 安全通道上的 CoAP Block-wise 传输。Block1 用于请求正文分块,Block2 用于响应正文分块;它们由 CoAP 处理,不是把业务 JSON 随意切成几段发送。RFC 7959

此次完整物模型响应为 2230 字节,测试使用 512 字节块,终端按 512×4+182 返回五块。业务层允许的 8KB 完整载荷上限,与单块 512 字节是两个不同的限制。

客户端发出请求时,还不知道服务端会返回一个小响应,还是需要继续获取后续分块。当前实现会为满足条件的请求准备 Block2 选项和请求快照,保存 token、URI、选项等后续续块所需的信息。一个只有几十或几百字节的普通请求,也可能进入这条准备路径。

这里需要区分两种状态:

状态用途合适的归属
请求快照等到响应后,能够识别原请求并构造续块请求各自的请求节点
活动分块上下文保存正在进行的大报文传输状态与重组数据实际分块传输

旧逻辑把两者绑在一起,快照一被占用,就提前阻塞了其他普通请求。

从发送失败找到过宽的保护条件

故障日志中,控制和物模型查询都在中枢直接转发阶段失败。去掉设备标识等无关字段后,关键内容为:

hub control direct send FAIL reason=send ret=-327656 ... uri=control
hub thingQuery direct send FAIL reason=send ret=-327656 ... uri=thingQuery

-327656 对应本实现的 IOTC_NET_TRANS_ERR_COAP_ENDPOINT_REQ_LIST_FULL。这个错误名称容易让人先怀疑请求队列真的满了,但同一个错误码也被资源占用保护分支复用。

旧版在 NetCoapClientSendReq() 中同时检查了活动 Block1、活动 Block2 和 block2Snap.tkl != 0。最后一个条件是关键:它只表示共享快照已保存,并不说明真的正在分块。中枢复用同一 DTLS endpoint 向不同对端发送请求,上报、心跳和子设备控制会经过这个入口。只要先前请求仍占用这份快照,后续普通控制就可能在进入底层发送之前被拒绝。

本地最小复现进一步排除了“队列已经堆满”的猜测:

步骤请求状态结果
发出普通上报,暂不回复pending=1,快照 token 长度为 8;Block1、Block2 均未活动发送成功
同一 endpoint 向另一对端发控制仍只有一条在途请求返回 -327656,发送计数没有增加
给第一条上报补回普通响应旧请求结束,共享快照清空回调执行
再发同样的控制共享快照已空闲发送成功

这解释了它为什么表现为偶发故障:用户点击控制的时刻,恰好落在其他请求等待响应的窗口内,就会失败。周期性心跳或上报会反复制造这样的窗口。若某次等待更久,受影响时间也会更长;这并不等于每次都发生了槽位泄漏。

修复前的流程图

下图聚焦“已准备好 Block2 快照的普通小请求”。其中保存快照,是为可能出现的大响应做准备,不代表已经开始分块。为突出根因,图中省略锁失败、内存不足等分支。

img

修复前的时序图

下面用“上报 A 等待确认时,控制 B 到达”说明竞争关系。它对应最小复现的状态变化,不是原始抓包的逐包时间线;A 的确认被刻意放到 B 失败之后。

img

将普通请求独立保存,只保护真正的大报文传输

修复的核心是把快照所有权从 endpoint 移到请求节点 NetCoapReqNode。只删掉 busy 判断还不够:如果两条请求继续共用一份快照,后发请求就会覆盖先发请求的续块信息。因此,需要一起调整发送登记、快照存储和响应清理。

下面三组代码均摘自 core/network/transport/coap/endpoint/net_coap_endpoint_client.c 的实际修改前后版本。只展示相关连续片段,函数前后的参数检查、加锁、发送失败处理等未完整展开;这些片段不是可独立编译的完整函数。

代码对照一:删除发送前的共享快照拦截

修复前,NetCoapClientSendReq() 在加锁成功后执行以下逻辑。即使 Block1 和 Block2 都未活动,只要 block2Snap.tkl != 0,也会拒绝当前请求:

if (block2Ready && (endpoint->client.block1.active || endpoint->client.block2.active ||
    endpoint->client.block2Snap.tkl != 0)) {
    EndpointUnlock(endpoint);
    UtilsBufferFree(&materialized);
    return IOTC_NET_TRANS_ERR_COAP_ENDPOINT_REQ_LIST_FULL;
}
if (block2Ready) {
    NetCoapBlockSnapCopy(&endpoint->client.block2Snap, &reqSnap);
}
packetBuf->header = buildPkt.header;
(void)memcpy_s(packetBuf->token, sizeof(packetBuf->token), buildPkt.token, buildPkt.header.tkl);
if (handler != NULL) {
    ret = NetCoapClientInsertReqLocked(endpoint, packetBuf, handler, &param->reqUserData);
}

修复后,普通请求不再受这份共享快照的占用限制,快照作为参数交给当前请求节点保存。仍然先登记请求,再释放锁并发包,避免快速响应到达时找不到请求记录:

packetBuf->header = buildPkt.header;
(void)memcpy_s(packetBuf->token, sizeof(packetBuf->token), buildPkt.token, buildPkt.header.tkl);
if (handler != NULL) {
    ret = NetCoapClientInsertReqLocked(endpoint, packetBuf, handler, &param->reqUserData,
        block2Ready ? &reqSnap : NULL);
}
EndpointUnlock(endpoint);
if (ret == IOTC_OK) {
    ret = NetCoapEndpointSendPacket(endpoint, &buildPkt, packetBuf, param->preSize, addr);
}

代码对照二:快照从“大家共用一份”改为“每条请求一份”

修复前,NetCoapClientInsertReqLocked() 只分配请求节点,快照放在上面那份 endpoint 共享位置:

NetCoapReqNode *newNode = (NetCoapReqNode *)IotcMalloc(sizeof(NetCoapReqNode));
if (newNode == NULL) {
    IOTC_LOGW("calloc error");
    return IOTC_ADAPTER_MEM_ERR_MALLOC;
}
(void)memset_s(newNode, sizeof(NetCoapReqNode), 0, sizeof(NetCoapReqNode));

修复后,只给需要快照的请求多分配一段尾部空间。对应结构体在 net_coap_endpoint_priv.h 中增加 NetCoapBlockReqSnap *block2Snap,并删除 endpoint 上原有的内嵌快照:

/* 快照按需放在节点尾部,与节点一次分配、一起释放。 */
uint32_t size = sizeof(NetCoapReqNode) + (snap != NULL ? sizeof(NetCoapBlockReqSnap) : 0U);
NetCoapReqNode *newNode = (NetCoapReqNode *)IotcMalloc(size);
if (newNode == NULL) {
    IOTC_LOGW("calloc error");
    return IOTC_ADAPTER_MEM_ERR_MALLOC;
}
(void)memset_s(newNode, sizeof(NetCoapReqNode), 0, sizeof(NetCoapReqNode));
if (snap != NULL) {
    newNode->block2Snap = (NetCoapBlockReqSnap *)(newNode + 1);
    NetCoapBlockSnapCopy(newNode->block2Snap, snap);
}

这样,请求 A 和请求 B 各自保存原始信息,不需要争抢一份 endpoint 级快照。快照与节点一次分配、一起释放,请求数量仍受原有请求表上限约束。

响应到达后,GetRespHandler() 先从匹配节点复制出快照,再释放节点。普通小响应交给自己的回调;如果后续需要重组,快照副本再交给活动分块上下文。请求节点不需要一直保留到整个大响应重组完成。

代码对照三:当前请求结束,不清掉别人的分块状态

允许普通请求并发后,还必须收窄清理范围。否则请求 B 的普通响应到达,可能清掉请求 A 正在使用的重组上下文。

修复前,NetCoapClientDispatchResp() 先要求请求与共享快照匹配。通过后,遇到解析错误、不带 Block2 或单块响应,就执行统一清理:

if (!NetCoapBlockTokenMatch(&endpoint->client.block2Snap, snap->token, snap->tkl)) {
    EndpointUnlock(endpoint);
    return;
}
/* 续块异常也必须交还原业务回调,清理后不能再进入内部续块回调。 */
bool restarted = ctx->active && present && info.num == 0U && !info.more;
if (ret != IOTC_OK || !present || (!info.more && info.num == 0U)) {
    if (ctx->active) {
        handler = ctx->respHandler;
        ud = ctx->reqUserData;
    }
    NetCoapClientBlock2ResetLocked(endpoint);
    EndpointUnlock(endpoint);
    if (handler != NULL) {
        bool failed = ret != IOTC_OK || restarted;
        handler(failed ? NULL : resp, addr, &ud, failed);
    }
    return;
}

修复后,通过活动上下文中的 token 判断当前响应是否属于这次分块传输。只有 current 为真才清理;其他普通响应仍使用自己的回调和业务数据:

bool current = ctx->active && NetCoapBlockTokenMatch(&ctx->snap, snap->token, snap->tkl);
/* 续块异常也必须交还原业务回调,清理后不能再进入内部续块回调。 */
bool restarted = current && present && info.num == 0U && !info.more;
if (ret != IOTC_OK || !present || (!info.more && info.num == 0U)) {
    if (current) {
        handler = ctx->respHandler;
        ud = ctx->reqUserData;
        NetCoapClientBlock2ResetLocked(endpoint);
    }
    EndpointUnlock(endpoint);
    if (handler != NULL) {
        bool failed = ret != IOTC_OK || restarted;
        handler(failed ? NULL : resp, addr, &ud, failed);
    }
    return;
}

实际分块响应的保护仍保留在后续分支:

/* 普通响应独立交付;只有实际分块响应才竞争唯一的重组上下文。 */
if (!current && (ctx->active || endpoint->client.block1.active)) {
    EndpointUnlock(endpoint);
    IOTC_LOGW("coap block2 context busy");
    if (handler != NULL) {
        handler(NULL, addr, &ud, true);
    }
    return;
}

这意味着本次没有扩展成多个并发大报文上下文。如果已有 Block1 上传或 Block2 重组,另一条请求随后也收到需要重组的大响应,仍会触发忙保护,并通过失败回调通知调用方。此处发生在收到响应之后,与原来普通请求在发送前返回 REQ_LIST_FULL 不同。它不会自动排队,也不能把回调中表示失败的 true 参数误解为已经等待了一次真实超时。

普通请求快照的修复涉及 3 个生产文件,新增 29 行、删除 42 行,主要修改请求节点、快照取用和占用判断。请求匹配及底层发送仍沿用原有实现;取消过宽的快照限制,也不代表取消请求数量上限或协议的其他约束。

修复后的流程图

发送阶段先给每条普通请求保存自己的信息;响应阶段再根据 Block2 选项和活动状态决定是否进入重组。下图省略异常块、续块发送和分配失败等细节。是否需要续块取决于协议字段,不能仅用“这一包是否超过 512 字节”判断。

img

修复后的时序图

仍使用同一组请求 A、B。这里故意让 B 先完成,说明后发请求不必等待 A。实际响应顺序由网络和业务处理决定;图中两条请求都返回普通小响应。

img

顺带补齐 TLS 分块上下文的定时回收

检查同一套分块资源生命周期时,还发现中枢定时任务只扫描了 DTLS 栈的 endpoint,遗漏了 TLS 栈。位置在 core/hub/application/coap/hub_sec_coap.c 的 HubSecSweepTimerCb()。

修复前,分块扫描只有原有栈:

if (ctx->stack.endpoint != NULL) {
    NetCoapServerSweepBlock1(ctx->stack.endpoint, (uint32_t)now);
}

修复后,保留原有扫描,再补上 TLS endpoint:

if (ctx->stack.endpoint != NULL) {
    NetCoapServerSweepBlock1(ctx->stack.endpoint, (uint32_t)now);
}
/* TLS 栈的分块上下文也需要周期回收。 */
if (ctx->tlsStack.endpoint != NULL) {
    NetCoapServerSweepBlock1(ctx->tlsStack.endpoint, (uint32_t)now);
}

虽然入口名叫 NetCoapServerSweepBlock1(),该版本内部还会级联检查客户端 Block1、Block2 和服务端 Block2 的超时状态。这四行让 TLS endpoint 上的过期 CoAP 分块上下文也进入已有回收流程。

这与普通控制被快照挡住是两个修复点。它回收的是超时分块状态,不是直接释放 TLS 连接。本次 DTLS 板端日志也不能当作 TLS 回收路径的运行验证。

用本地复现和三端结果验证修复

本地先保留旧版“只有一条普通请求在等待,第二条控制就失败”的复现,再验证修复后的行为。围绕本次客户端修改,10 项新增用例和 25 项既有用例通过,覆盖普通请求并发、活动分块期间的小请求、其他请求失败时的隔离,以及第二条大响应的忙保护。

这些测试使用实际客户端与分块实现,但发送、时间、锁等由桩模拟。它们验证状态和回调行为,真实网络是否完成还需要三端日志确认。

2026 年 10 月 26 日晚,配置器主要操作时间为 21:29:12~21:30:41。按请求标识核对结果,而不是把三端未对齐的时钟直接相减,得到以下记录:

操作本轮结果
5 次开灯全部返回 errCode=0,终端执行成功
6 次异步关灯先返回 101,随后收到同一 reqId 的完成上报,配置器显示 async ok
1 次物模型查询配置器收到完整 2230 字节,errCode=0,约 0.8 秒完成
1 次属性查询成功返回开关状态
WS63 到中枢心跳中枢截取区间内连续 4 次 heartbeat OK,间隔约 30 秒

101 是本项目异步控制的处理中状态,不是 CoAP 协议响应码。判断这六次关灯成功的依据,是后续同一请求的完成上报和界面结果,而不是仅仅看到首次响应。

日志里还有一次有价值的交错:21:29:59.814,配置器发出控制;21:29:59.987,又收到中枢上报并回复;21:30:00.026,控制结果正常返回。两条业务在时间上重叠,最终都完成,未复现此前的忙拒绝。

物模型查询则提供了完整的分块和业务交付证据。中枢依次记录:

coap block2 recv num=0 more=1 len=512 total=512
coap block2 recv num=1 more=1 len=512 total=1024
coap block2 recv num=2 more=1 len=512 total=1536
coap block2 recv num=3 more=1 len=512 total=2048
coap block2 recv num=4 more=0 len=182 total=2230

随后配置器收到 nativeErr=0、coapCode=0x45、bodyLen=2230,解析得到 errCode=0 和完整物模型。这里验证到了业务层,避免了“底层收齐就认为查询成功”的误判。

这轮通过的范围,以及仍需分开看待的问题

物模型链路中,WS63 到中枢走了 Block2;中枢到配置器则有明确日志:

coap block2 skip: peer not use block transfer, len=2230

因此,本轮证明的是“终端分块发送、中枢重组、配置器整包接收”。控制器的标准分块接收和大请求上传仍需单独测试。日志将正文按 700 字节分段打印,也不等于网络上使用了 Block2。

本轮还留下四次约每 30 秒出现的 hub report push resp timeout (self configurator unreachable)。结合调用路径,它们属于中枢自身向配置器发心跳后的响应超时;第三次之后触发了中枢自身的激活重试。同期 WS63 到中枢的心跳和子设备控制继续正常。这些超时缺少足够的报文关联信息,暂不能判定是配置器漏回,还是中枢响应匹配未完成,应作为独立问题继续跟踪。

此次约 90 秒的控制操作和一次物模型查询,是修复有效的回归证据;多设备长期运行、丢包和并发大报文场景还需进一步验证。

这次故障带来的直接经验是:给共享资源加保护时,需要先确定资源属于谁。普通请求的快照应跟随请求管理,活动分块上下文则保护真正的大报文传输。把这两种生命周期分清楚,才能既防止状态互相覆盖,又让不相关的控制和心跳继续执行。

Logo

社区规范:仅讨论OpenHarmony相关问题。

更多推荐