↓メインコンテンツへスキップ

Claude Code の応答が途中で止まる問題を調べた

ここ数日、Claude Code のセッションでWaiting for API responseのまま応答が止まり、最終的にAPI Error: The response stopped arriving. The response above may be incomplete.になることが多かった。原因は Linksys Velop WRT Pro 7 の設定でnet.ipv6.conf.all.proxy_ndpが 0 になっていたことだった。

なお、記事中の IPv6 アドレスは、ドキュメント用のアドレス(2001:db8::/32)に置き換えている。

原因 #

Claude Code のトランスクリプトから API エラーを抜き出すと、次のようになっていた。

日時(JST)メッセージ
10-17 13:53Connection lost mid-response
10-24 21:18Connection lost mid-response
10-25 08:10Connection lost mid-response(2 件)
10-25 09:11The response stopped arriving
10-25 09:24The response stopped arriving
10-25 09:34The response stopped arriving
10-25 13:51Connection lost mid-response
10-25 14:36Connection lost mid-response

The response stopped arrivingの 3 件は、どれも同じ経過をたどっており、リクエストから 2〜4 秒で最初の出力(8〜32 トークン)が届き、その後は何も届かないまま約 180 秒でエラーになっていた。

デバッグログには、ストリームが途切れたときに次のような警告が出ていた。

[WARN] [Stall] stream_idle_partial lastChunkAgeMs=30001 bytesTotal=2161 idleDeadlineMs=180000

Claude Code は IPv6 でapi.anthropic.com(2607:6bc0::10、AS399358 Anthropic)に接続しており、8 ホップ、約 7ms で国内の Cloudflare に到達していた。

hopアドレス所属RTT
12001:db8:1:1::1自宅ルーター2.6–2.9ms
22001:db8:1:1::fffe上流(NTT 側)3.5–3.7ms
3–4* *応答なし–
52404:8e00:feed:ff2e::2AS55391 INTERNET MULTIFEED6.4ms
62001:7fa:7:1:0:1:3335:2IX(BGP には広告されていない)7.1ms
7–82400:cb00:...AS13335 Cloudflare7.0–7.8ms

IPv4 では、ルーターから transix の DS-Lite を経由し、5 ホップで160.79.104.10に到達した。どちらの経路でも、traceroute を実行した時点ではロスや遅延の異常は見られなかった。

ルーターのログを遡ろうとしたものの、log_sizeがデフォルトの 64KB だったため、tailscaled のログで埋まってしまい、約 8 分しか残っていなかった。ストールが起きた時刻のログは、すでに消えていた。そこで、ログのバッファーを 8MB に増やしてみた。

# uci set system.@system[0].log_size=8192
# uci commit system
# /etc/init.d/log restart

ログを延ばした後にストールが何度か起きたので、ルーターのログと突き合わせた。カーネルの経路変更を監視している tailscaled のログを見ると、Mac の IPv6 アドレス(2001:db8:1:1:8c31:8c0e:f7f4:352d)宛ての/128経路が追加され(RTM_NEWROUTE)、同じ秒のうちに削除される(RTM_DELROUTE)ことが、約 6 分おきに「10 秒間隔で 4 回」まとまって起きていた。ストールは、このまとまりと秒単位で一致していた。

Mac のRTM_DELROUTE(JST)Claude Code
16:56:25〜16:56:5516:56:57 ECONNRESET
17:02:19〜17:02:49リクエストなし
17:08:08〜17:08:3817:08:07 からストール
17:13:57〜17:14:2717:14:07 からストール
17:19:45、17:19:55、17:20:05、17:20:1517:19:48、17:19:56、17:20:05 にECONNRESET、17:20:15 からストール
17:25:36〜17:26:0617:25:47 ECONNRESET、17:25:56 からストール

ストリームが流れているときにこのまとまりが来ると失敗し、流れていなければ何も起きなかった。まとまりの外で失敗したことは一度もなかった。同じパターンは、Mac 以外の LAN 端末でも、それぞれ別の周期で起きていた。

この経路の追加と削除は、odhcpd の relay の実装で説明できた。インストールされている odhcpd(2020-05-03、49e4949c)のsrc/ndp.cでは、relay しているインターフェースで NS(近隣要請)を受け取ると、他の relay インターフェースでping6()を呼んでいた。ping6()は近隣キャッシュを更新するためのもので、対象アドレスへの一時的な/128経路(metric 128)を張って ICMP Echo を送り、すぐにその経路を消していた。

netlink_setup_route(addr, 128, iface->ifindex, NULL, 128, true);
odhcpd_send(iface->ndp_ping_fd, &dest, &iov, 1, iface);
netlink_setup_route(addr, 128, iface->ifindex, NULL, 128, false);

Mac 宛ての一時経路はbr-lanに張られていたので、Mac のアドレスを問い合わせる NS は WAN 側から届いていたと分かった。つまり、NTT 側のルーターが Mac のアドレスについて、10 秒おきに 4 回 NS を送っていた。

最初は、この一時経路の変化をきっかけに、ルーターの QCA NSS のハードウェアオフロード(ECM)から接続が外れるのではないかと考えた。外れた後は conntrack のシーケンス番号の追跡がずれるので、INVALID と判定されてファイアウォールで落とされる、という見立てだった。実際に、ストールした接続は ECM から消えていて、conntrack ではESTABLISHEDのまま更新が止まっていた。

そこで、ECM の IPv6 オフロードを止めてみようとした。

# echo 1 > /sys/kernel/debug/ecm/front_end_ipv6_stop

しかし、この値は 10 秒ほどで 0 に戻ってしまい、何が戻しているのかも分からなかった。

この見立て自体は、否定されたわけではなかった。ただ、一時経路が張られた後に何が起きるかばかりを見ていて、そもそも NS が繰り返される理由を見落としていた。NUD(Neighbor Unreachability Detection)の NS は、相手から NA(近隣広告)が返れば 1 回で終わるはずだった。NTT 側のルーターが 10 秒おきに 4 回も NS を送っているのは、NA が返っていないからだと考えるべきだった。

NTT 側のルーターが NUD で送る NS は、Mac のアドレス宛てのユニキャストだった。この NS は自宅ルーター宛てではないので、Linux では受信処理ではなく転送処理(ip6_forward())に回っていた。Linux 5.4 のnet/ipv6/ip6_output.cを見ると、転送処理で proxy NDP を扱うかどうかは、all.proxy_ndpだけで判定していた。

    /* XXX: idev->cnf.proxy_ndp? */
    if (net->ipv6.devconf_all->proxy_ndp &&
        pneigh_lookup(&nd_tbl, net, &hdr->daddr, skb->dev, 0)) {
        int proxied = ip6_forward_proxy_check(skb);
        if (proxied > 0)
            return ip6_input(skb);

一方、マルチキャストの NS を扱うnet/ipv6/ndisc.cは、all.proxy_ndpとインターフェースごとのproxy_ndpのどちらかが 1 なら proxy として応答していた。

            (idev->cnf.forwarding &&
             (net->ipv6.devconf_all->proxy_ndp || idev->cnf.proxy_ndp) &&

自宅ルーターの値を確認すると、eth4とbr-lanのproxy_ndpは odhcpd が 1 にしていたが、all.proxy_ndpは 0 だった。

# for i in all eth4 br-lan; do echo "$i proxy_ndp=$(cat /proc/sys/net/ipv6/conf/$i/proxy_ndp)"; done
all proxy_ndp=0
eth4 proxy_ndp=1
br-lan proxy_ndp=1

このため、アドレス解決のためのマルチキャストの NS には応答できていたが、NUD のユニキャストの NS には応答できていなかった。NTT 側のルーターは NUD に失敗し、Mac のアドレスについて NS を繰り返していた。ストールは、この繰り返しと秒単位で一致していた。

ただし、接続が途切れる仕組みまでは確定できなかった。NUD に失敗した NTT 側のルーターが Mac 宛てのパケットを落としているのか、NS のたびに odhcpd が張る一時経路で ECM から接続が外れているのか、手元のログからは見分けられなかった。

対策 #

all.proxy_ndpを 1 にした。

# sysctl -w net.ipv6.conf.all.proxy_ndp=1

適用後は、各端末で NS のまとまりが「10 秒間隔で 4 回」から 1 回に減った。NTT 側のルーターの NUD に、proxy で応答できるようになったということだった。

端末適用前適用後(JST)
Mac10 秒間隔で 4 回(約 6 分おき)1 回(18:07:39、18:13:10、18:18:38、18:24:18)
他の LAN 端末10 秒間隔で 3〜4 回1 回

適用後の約 20 分間に Claude Code が送った 36 リクエストでは、ストールとECONNRESETのどちらも起きなかった。ただし、odhcpd は NS を受けるたびに一時経路を張るので、NS が 1 回に減っても一時経路の変化は残っていた。適用後に Mac 宛ての NS は 4 回来たが、どれも Claude Code がリクエストを送っていない時間帯だったので、ストリームの途中で 1 回だけの NS が来たときに接続が途切れないかは、確認できなかった。

sysctl -wでの変更は、ルーターを再起動すると元に戻ってしまうため、次の手順で永続化した。

まず、/etc/sysctl.d/に設定ファイルを作った。同じディレクトリーの99-auto-ipoe.confにもproxy_ndpの設定が並んでいるが、これはファームウェアの mapv6 が生成し直したり削除したりするファイルなので、追記せずに別のファイルにした。

# cat > /etc/sysctl.d/99-proxy-ndp.conf <<'EOF'
# Answer unicast NUD probes from the upstream router for relayed LAN addresses.
# ip6_forward() checks only all.proxy_ndp, not the per-interface value set by odhcpd.
net.ipv6.conf.all.proxy_ndp=1
EOF

/etc/init.d/sysctlは起動時に/etc/sysctl.d/*.confを読み込むので、これで再起動後も有効になった。作ったファイルをすぐに読み込んで、値も確認した。

# sysctl -p /etc/sysctl.d/99-proxy-ndp.conf
net.ipv6.conf.all.proxy_ndp = 1
# cat /proc/sys/net/ipv6/conf/all/proxy_ndp
1

次に、ファームウェアを更新しても残るように、/etc/sysupgrade.confに追加した。sysupgrade -lで、保持されるファイルの一覧に入ったことも確認した。

# echo /etc/sysctl.d/99-proxy-ndp.conf >> /etc/sysupgrade.conf
# sysupgrade -l | grep sysctl
/etc/sysctl.conf
/etc/sysctl.d/99-proxy-ndp.conf