Skip to content

Half-close TCP connections so the last write is not lost#958

Merged
ianmcorvidae merged 1 commit into
masterfrom
tcp-graceful-close
Jul 17, 2026
Merged

Half-close TCP connections so the last write is not lost#958
ianmcorvidae merged 1 commit into
masterfrom
tcp-graceful-close

Conversation

@caveman99

@caveman99 caveman99 commented Jul 17, 2026

Copy link
Copy Markdown
Member

Fixes #957.

One-shot config writes over TCP are silently lost on Windows: --set reports
success and the device never applies it. Reads are unaffected.

Cause

close() went straight to shutdown(SHUT_RDWR) + close(). Doing that while
either side still has unread data makes the stack send RST rather than FIN,
and the device's FromRadio stream means there is essentially always unread data.

Winsock discards data that was received but not yet read when an RST arrives,
so the admin message written moments earlier is thrown away before the device gets
to read it. Linux delivers the buffered bytes before reporting ECONNRESET, which
is why this only surfaced on Windows.

writeConfig() does not wait for an ack, so close() runs microseconds after
sendall().

Fix

Send FIN first via shutdown(SHUT_WR), so the device can consume what we wrote,
wait briefly for the reader thread to drain and exit, then tear down exactly as
before.

The existing shutdown(SHUT_RDWR) is deliberately kept: it unblocks a reader
still sitting in recv() so StreamInterface.close() can join it. The added wait
is bounded (GRACEFUL_CLOSE_TIMEOUT, 0.25s) and returns early when the reader
exits, because a device is not obliged to close just because we half-closed.

Why not fix it device-side

It is a race with a tiny window, and the device already loses it. Bisecting the
linger between writeConfig() and close(), same device and command:

linger result
0s (current behaviour) region UNSET - lost
0.05s region applied
0.2s / 10s region applied

50ms suffices. meshtasticd polls its socket every 5ms and drains in a loop, so it
is not slow - the RST simply beats its next read. Nothing on the device can read
faster than the gap between the client's send() and its close().

Testing

  • pytest meshtastic/tests/test_tcp_interface.py - 9 passed.
  • Two new unit tests: one asserts the half-close/wait/full-shutdown ordering, one
    asserts close() survives a peer that has already vanished. The ordering test
    fails if close() is reverted to its previous behaviour, so it pins the
    regression rather than just describing it.
  • End-to-end against a real device (meshtasticd, Windows 11, Python 3.12), same
    command both times:
    • stock CLI: lora.region : 0 (UNSET) - lost
    • patched: lora.region : 1 (US) - applied

Behaviour on Linux/macOS is unchanged apart from up to 0.25s of extra latency on
close, and less of it in practice since the wait ends as soon as the reader exits.

Summary by CodeRabbit

  • Bug Fixes

    • Improved TCP connection shutdown to close gracefully before forcing termination.
    • Added a brief wait for active reader processes to exit cleanly.
    • Improved resilience when the remote connection is already unavailable, ensuring sockets still close without errors.
  • Tests

    • Added coverage for graceful shutdown ordering and failure handling.

close() went straight to shutdown(SHUT_RDWR) + close(). Doing that while either
side still has unread data makes the stack send RST rather than FIN, and the
device's FromRadio stream means there is essentially always unread data.

Winsock discards data that was received but not yet read when an RST arrives, so
an admin message written moments earlier is thrown away before the device reads
it. Linux delivers the buffered bytes before reporting ECONNRESET, which is why
this only surfaced on Windows: every one-shot TCP write there (--set, --seturl,
--sendtext) reported success and did nothing.

writeConfig() does not wait for an ack, so close() runs microseconds after
sendall(). The device cannot compensate; it polls every 5ms and the RST beats its
next read.

Send FIN first with shutdown(SHUT_WR), which lets the device consume what we
wrote, wait briefly for the reader thread to drain and exit, then tear down
exactly as before. The full shutdown is kept so a reader still blocked in recv()
is unblocked for the join in StreamInterface.close(). The wait is bounded: the
device is not obliged to close just because we half-closed.

Fixes #957
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

TCPInterface.close() now performs a bounded graceful half-close, waits for the reader thread, and then forces socket cleanup. Tests verify operation ordering and resilience when socket shutdown raises OSError.

Changes

TCP graceful shutdown

Layer / File(s) Summary
Graceful TCP close flow
meshtastic/tcp_interface.py
Adds GRACEFUL_CLOSE_TIMEOUT, a reader-exit wait helper, and a best-effort SHUT_WR before forced socket shutdown and closure.
Close behavior validation
meshtastic/tests/test_tcp_interface.py
Tests half-close and reader-wait ordering, and verifies cleanup continues when socket shutdown raises OSError.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant TCPInterface
  participant Socket
  participant Peer
  participant ReaderThread
  TCPInterface->>Socket: shutdown(SHUT_WR)
  Socket-->>Peer: FIN permits remaining data consumption
  TCPInterface->>ReaderThread: wait up to GRACEFUL_CLOSE_TIMEOUT
  TCPInterface->>Socket: forced shutdown
  TCPInterface->>Socket: close
Loading

Suggested reviewers: ianmcorvidae

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the main TCP shutdown fix.
Linked Issues check ✅ Passed The changes implement the requested SHUT_WR half-close and graceful teardown needed to fix lost TCP writes on Windows.
Out of Scope Changes check ✅ Passed The added timeout helper, constant, and tests are directly related to the shutdown fix and do not appear out of scope.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch tcp-graceful-close

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
meshtastic/tcp_interface.py (1)

117-119: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Consider skipping the wait if the half-close fails.

If shutdown(SHUT_WR) raises an exception (e.g., the peer has already vanished or the socket is disconnected locally), the connection is already broken. In this case, waiting for the peer to consume data is unnecessary, and you can safely skip the 0.25-second delay by grouping the wait inside a try block.

💡 Proposed optional refactor
-            with contextlib.suppress(Exception):
-                self.socket.shutdown(socket.SHUT_WR)
-            self._wait_for_reader_exit(GRACEFUL_CLOSE_TIMEOUT)
+            try:
+                self.socket.shutdown(socket.SHUT_WR)
+                self._wait_for_reader_exit(GRACEFUL_CLOSE_TIMEOUT)
+            except Exception:
+                pass
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@meshtastic/tcp_interface.py` around lines 117 - 119, Update the socket close
logic around self.socket.shutdown(socket.SHUT_WR) so
_wait_for_reader_exit(GRACEFUL_CLOSE_TIMEOUT) runs only when the half-close
succeeds. Keep exception suppression for shutdown failures and skip the wait
when shutdown raises.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@meshtastic/tcp_interface.py`:
- Around line 117-119: Update the socket close logic around
self.socket.shutdown(socket.SHUT_WR) so
_wait_for_reader_exit(GRACEFUL_CLOSE_TIMEOUT) runs only when the half-close
succeeds. Keep exception suppression for shutdown failures and skip the wait
when shutdown raises.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 86b484f8-7bc9-4b4a-a3c3-2bc9a62bab34

📥 Commits

Reviewing files that changed from the base of the PR and between 82220ba and f623732.

📒 Files selected for processing (2)
  • meshtastic/tcp_interface.py
  • meshtastic/tests/test_tcp_interface.py

@codecov

codecov Bot commented Jul 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.00000% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 67.40%. Comparing base (82220ba) to head (f623732).

Files with missing lines Patch % Lines
meshtastic/tcp_interface.py 90.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #958      +/-   ##
==========================================
+ Coverage   67.38%   67.40%   +0.02%     
==========================================
  Files          25       25              
  Lines        4752     4762      +10     
==========================================
+ Hits         3202     3210       +8     
- Misses       1550     1552       +2     
Flag Coverage Δ
unittests 67.40% <90.00%> (+0.02%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@ianmcorvidae

Copy link
Copy Markdown
Contributor

Looks reasonable to me. I'll pull it in shortly, just seeing if the simradio_testing daily job failure is actually anything or just noise.

@caveman99

Copy link
Copy Markdown
Member Author

It happened twice in a row now.

@caveman99

Copy link
Copy Markdown
Member Author

What actually fails. A single test: test_smokemesh_hop_limit_prevents_relay. It spins up simulated mesh nodes, has A send a broadcast with hopLimit=0, and asserts B receives it directly. B never receives it (assert col_b.wait_for(1) → False). This is firmware-side RF-sim packet delivery — it exercises none of the code your PR touches (the TCP client's graceful half-close). Your change can't cause it and can't fix it.

Why "repeatedly." The daily PPA rebuilds nightly from firmware develop. PR #946's daily leg passed on 2026-07-07; PR #958's fails on 2026-07-17 — a different, later daily firmware. So a firmware change in that 10-day window altered hopLimit=0 direct-delivery behaviour (B no longer receives the direct broadcast), and every re-run of your PR pulls that same broken daily build, so it fails the same test every time. The stable alpha/beta firmware still behaves correctly.

Bottom line: nothing to fix in PR #958 — the daily leg is informational and green-lights the merge regardless. The real signal is a firmware-side regression around hopLimit=0 delivery that belongs in the firmware repo, not here.

@ianmcorvidae
ianmcorvidae merged commit e6f0712 into master Jul 17, 2026
33 of 36 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TCP config writes (--set etc.) are silently lost on Windows

2 participants