Skip to content

Fix #126: Release mutex before async write in send_to_connection#145

Open
mcurrier2 wants to merge 1 commit into
mainfrom
fix/126-mutex-held-across-await
Open

Fix #126: Release mutex before async write in send_to_connection#145
mcurrier2 wants to merge 1 commit into
mainfrom
fix/126-mutex-held-across-await

Conversation

@mcurrier2

Copy link
Copy Markdown
Collaborator

Summary

  • Fix TCP and UDS send_to_connection() to not hold the connections mutex across the write_message().await call
  • Use remove/write/re-insert pattern: take stream out of map, release lock, perform async write, re-insert stream
  • Add warn! log in get_active_connections() when try_lock() fails, making the behavior observable
  • This allows concurrent multi-client operations during writes and reduces deadlock risk

Test plan

  • TCP multi-client test passes (test_tcp_multi_client)
  • UDS multi-client test passes (test_unix_domain_socket_multi_client)
  • All integration tests pass
  • Clippy clean, MSRV 1.70 compatible

Fixes #126

Made with Cursor

@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes
    • Improved connection handling during message sends to reduce the chance of blocked or stalled communication.
    • Added a warning when the app can’t access the current connection list, making connection-related issues easier to spot.

Walkthrough

Both TCP and Unix domain socket IPC transports change send_to_connection to remove the stream from the shared connections map, perform the async write outside the lock, then reinsert the stream. get_active_connections now logs a warning instead of silently returning an empty list on lock failure.

Changes

IPC connection locking fix

Layer / File(s) Summary
TCP socket transport locking fix
src/ipc/tcp_socket.rs
send_to_connection removes the stream from the map, awaits write_message outside the lock, reinserts the stream, and maps write failures to anyhow errors; get_active_connections warns on try_lock failure.
Unix domain socket transport locking fix
src/ipc/unix_domain_socket.rs
send_to_connection removes the stream from the map, awaits write_message outside the lock, and reinserts the stream; get_active_connections warns on lock failure.

Estimated code review effort: 2 (Simple) | ~10 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant SendToConnection
  participant ConnectionsMap
  participant Stream

  Caller->>SendToConnection: send_to_connection(id, message)
  SendToConnection->>ConnectionsMap: lock and remove stream
  ConnectionsMap-->>SendToConnection: stream
  SendToConnection->>Stream: write_message(message).await
  Stream-->>SendToConnection: write result
  SendToConnection->>ConnectionsMap: lock and reinsert stream
  SendToConnection-->>Caller: result
Loading

Related issues: #126 – Fixes mutex held across await in send_to_connection for both TCP and Unix domain socket transports, and addresses misleading empty results from get_active_connections under lock contention.

Suggested labels: bug, concurrency, performance

Suggested reviewers: (repository maintainers familiar with the IPC transport module)

Poem
A rabbit hopped through mutex gates,
Freed the lock before it waits,
Streams removed, then writes take flight,
Reinserted, all locks tight,
No more silence when contention's near—
Warnings now, so we can hear! 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: releasing the mutex before async writes in send_to_connection.
Description check ✅ Passed It includes a clear summary, testing, and issue reference, though the type-of-change and checklist sections are not filled out.
Linked Issues check ✅ Passed The TCP and UDS changes match #126 by avoiding holding the mutex across await and making try_lock contention visible.
Out of Scope Changes check ✅ Passed No clearly out-of-scope changes are present; the added warning log aligns with the linked issue's suggested behavior.
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

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.

@dustinblack dustinblack left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed: correct remove/write/re-insert pattern for mutex-across-await fix. send_to_connection has zero callers, so no runtime impact.

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

📈 Changed lines coverage: 0.00% (0/10)

🚨 Uncovered lines in this PR

  • src/ipc/tcp_socket.rs: 471, 496-497, 499, 503
  • src/ipc/unix_domain_socket.rs: 436, 461-462, 464, 468

📊 Code Coverage Summary

File Line Coverage Uncovered Lines
src/benchmark.rs 84.13%
(509/605)
75, 78, 89, 93, 102, 105, 107, 124, 424, 429-434, 441-446, 513-516, 621, 705, 717-719, 739, 808-810, 815, 836, 841, 859, 965, 969-972, 974, 983-986, 988, 1064, 1095, 1098, 1100-1101, 1110-1111, 1253, 1266, 1283, 1406, 1415, 1417-1418, 1421-1422, 1428-1429, 1434-1435, 1437, 1442-1443, 1447-1449, 1454-1455, 1458-1459, 1491, 1549-1553, 1555-1561, 1563, 1566, 1723, 1738
src/benchmark_blocking.rs 74.32%
(327/440)
97, 111, 127, 263, 369, 380-382, 402, 434, 495, 594, 607, 621, 651-654, 739-742, 761, 765, 780, 822, 824-826, 830, 833-835, 837, 840, 842-846, 848-849, 857-861, 863-867, 870-871, 875-876, 911, 960, 1042, 1053, 1083, 1086, 1151-1156, 1158, 1216-1219, 1224, 1237, 1240-1243, 1247, 1249-1252, 1254, 1256-1257, 1259-1260, 1263, 1265-1270, 1272, 1276-1277, 1279, 1281, 1305, 1317-1322, 1324, 1344-1347
src/cli.rs 92.39%
(85/92)
691, 790, 830, 832, 853-855
src/execution_mode.rs 100.00%
(14/14)
``
src/ipc/mod.rs 63.64%
(49/77)
134, 458, 460-463, 773-774, 789-790, 808-809, 840, 843, 846, 851, 878-879, 893, 895, 915, 917, 1040-1042, 1231-1233
src/ipc/posix_message_queue.rs 46.09%
(59/128)
139-140, 213-215, 217, 224, 229, 332-335, 337, 345, 437, 441-442, 446, 449-452, 454-458, 539, 679, 782, 789-790, 807-808, 819-820, 831-832, 849-850, 906, 910-911, 914-919, 921-923, 927, 929-931, 933, 935-937, 941-943, 945-947, 994-995, 1017
src/ipc/posix_message_queue_blocking.rs 72.16%
(127/176)
172, 182, 221, 251-255, 274, 325, 368, 387-390, 416-418, 422-423, 425-426, 436, 455, 457-458, 460-461, 474-475, 477-478, 484, 486-491, 493-495, 497-498, 503, 505-506, 508-509
src/ipc/shared_memory.rs 69.36%
(163/235)
69, 152, 156, 257-258, 268-269, 273, 401-402, 428-430, 432, 450-452, 454-455, 457-461, 478, 485, 491, 494-495, 499, 503, 507-508, 513-514, 677-678, 681-682, 685, 687, 692-693, 720-721, 724-725, 732-734, 736, 738-743, 745-746, 749-750, 752-756, 763, 793, 795-796, 798, 802
src/ipc/shared_memory_blocking.rs 77.35%
(222/287)
199-201, 203-204, 207-209, 212-213, 215, 220, 222, 226-228, 233, 241-243, 246-248, 251-252, 254, 257, 260-261, 264-265, 269-270, 272, 276-277, 279, 315-316, 403-404, 428-432, 544, 552, 602, 619, 706, 772, 805-806, 808-809, 816, 833, 835-836, 838, 871, 880, 890, 912
src/ipc/shared_memory_direct.rs 83.25%
(159/191)
400-403, 472-479, 483, 511, 536-539, 543-544, 590-591, 603, 633, 640-641, 697-698, 704, 726, 731-732
src/ipc/tcp_socket.rs 58.88%
(63/107)
31-32, 61, 96, 113-114, 118, 124-125, 129, 136-137, 141, 147-148, 152, 171-172, 175-177, 184-185, 188, 362-363, 366-367, 370-371, 376-377, 422, 429, 447-449, 471, 495-497, 499, 503, 508
src/ipc/tcp_socket_blocking.rs 94.23%
(98/104)
145, 170, 322-325
src/ipc/unix_domain_socket.rs 58.88%
(63/107)
29-30, 58, 93, 103, 122-123, 127, 133-134, 138, 145-146, 150, 156-157, 161, 180-181, 184-186, 193-194, 197, 346-347, 350-351, 354-355, 360-361, 412-414, 436, 460-462, 464, 468, 473, 489
src/ipc/unix_domain_socket_blocking.rs 92.06%
(116/126)
287-288, 294-296, 298, 363-366
src/logging.rs 100.00%
(13/13)
``
src/main.rs 45.76%
(162/354)
85-87, 89, 130-131, 141-145, 149-151, 153-154, 156-157, 177-180, 204-208, 216, 222, 225, 230-233, 238-239, 245, 251, 253-255, 257, 263-264, 270, 275, 278-279, 283, 285-286, 290-291, 293, 299, 303-304, 306-311, 313-314, 317, 326, 329-330, 333, 380-383, 390, 392-396, 399-402, 404-405, 407-408, 410, 412-418, 422, 424-427, 430, 434-436, 440, 442, 445, 449, 454-457, 463-464, 470-471, 477, 479-480, 484, 486, 491-493, 497, 500-501, 503-504, 509, 511-513, 517-518, 520, 527, 532-533, 535-540, 542-543, 547, 556, 559-560, 563, 565, 584, 591, 595-597, 599, 627-628, 636, 669, 704, 729, 733, 736-739, 795-796, 803-804, 807, 834-835, 838, 875, 896-897, 901-904, 926, 953, 962, 967, 972-973
src/metrics.rs 84.04%
(158/188)
455-460, 493-494, 552, 558, 579-582, 732-734, 736, 768, 833, 838, 881, 904, 952, 980, 984, 1005, 1007-1008, 1013
src/results.rs 56.60%
(253/447)
726, 735-737, 739-740, 743-744, 747, 769, 772-773, 776, 778, 781, 785-790, 800-801, 804-809, 826, 838-839, 841, 843, 846-847, 849, 853, 880, 904-906, 909-910, 914-916, 919, 945, 950, 955, 961, 980, 982-983, 985, 987-991, 993, 995-996, 1030, 1071-1072, 1075, 1081-1082, 1086, 1090-1092, 1094-1095, 1119-1123, 1126-1129, 1132-1141, 1151-1152, 1171-1172, 1174-1178, 1180, 1197-1198, 1200-1205, 1207, 1225, 1227-1232, 1250, 1253, 1269-1270, 1285-1287, 1289-1291, 1293-1294, 1296-1297, 1299-1300, 1302, 1304-1305, 1307-1310, 1312-1314, 1316-1318, 1321, 1325-1326, 1334-1339, 1341-1342, 1346-1347, 1351-1353, 1355, 1359-1360, 1369-1372, 1376-1378, 1382, 1384-1385, 1393-1394, 1399, 1406-1410, 1412, 1610-1611, 1831-1832, 1834-1835
src/results_blocking.rs 95.51%
(298/312)
489-490, 492-493, 544, 769, 774, 779, 815, 818-819, 827-828, 886
src/standalone_client.rs 84.57%
(636/752)
40, 68-69, 79, 84-85, 87, 96-99, 109-111, 113, 117, 133, 154, 256, 276, 320, 361, 365, 396, 414, 513, 531, 542, 549, 614, 617-618, 626-646, 648-649, 651, 675, 682, 698, 736, 739, 761-763, 768-769, 857, 877, 893, 921, 924-925, 962, 966, 997, 1001, 1015, 1022, 1105, 1123, 1134, 1141, 1144, 1201, 1204-1205, 1213-1233, 1235-1236, 1238, 1262, 1269, 1272, 1284, 1321, 1324
src/standalone_server.rs 77.97%
(400/513)
138, 161, 187, 204-205, 207, 217-219, 221, 260, 285-286, 309-311, 335-336, 421-422, 425-426, 446-447, 457-458, 461, 463, 465, 475-478, 485, 493-496, 521, 553-554, 569-570, 580-581, 584, 586, 588, 598-601, 608, 619-622, 668, 747-748, 766-768, 772-774, 824-826, 830-831, 834-835, 851-852, 859-862, 871, 876-877, 880, 882, 884, 894, 901-904, 926, 956-958, 962-963, 975-976, 983-986, 995, 1000-1001, 1004, 1006, 1008, 1018, 1027-1030
src/utils.rs 75.00%
(42/56)
143, 147-148, 153, 159, 198-202, 239-242
Total 75.43%
(4016/5324)

@sberg-rh

sberg-rh commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Definitely not an expert on rust, but claude brought up a point (perhaps already discussed elsewhere) that I thought was interesting. Apparently the tokio::sync::Mutex used in rusty is actually designed for this purpose as a async-aware lock so it doesn't block on .await and can be used to hold a lock across an async operation. In theory, the existing code should be fine as is. Is there more context for the change?

Both TCP and UDS send_to_connection() held the connections mutex for
the entire duration of write_message().await, serializing all multi-
client writes and risking deadlock under high contention. Additionally,
get_active_connections() silently returned an empty vec when the lock
was contended, making it appear as if there were no active connections.

Fix send_to_connection in both transports by removing the stream from
the map, releasing the lock, performing the async write, then
re-inserting the stream. This allows other connection operations to
proceed concurrently during writes.

For get_active_connections, add a warning log when the lock cannot be
acquired so the behavior is observable (the try_lock pattern is
retained since this is a sync fn called from async context and must
not block the executor).

- TCP: remove/write/re-insert pattern in send_to_connection
- UDS: same remove/write/re-insert pattern
- Both: add warn! log in get_active_connections try_lock failure path
- All tests passing including multi-client tests, clippy clean

AI-assisted-by: Claude Opus 4.6
Co-authored-by: Cursor <cursoragent@cursor.com>
@dustinblack
dustinblack force-pushed the fix/126-mutex-held-across-await branch from 9ed422d to 1e063e0 Compare July 6, 2026 16:44
@dustinblack

Copy link
Copy Markdown
Collaborator

Definitely not an expert on rust, but claude brought up a point (perhaps already discussed elsewhere) that I thought was interesting. Apparently the tokio::sync::Mutex used in rusty is actually designed for this purpose as a async-aware lock so it doesn't block on .await and can be used to hold a lock across an async operation. In theory, the existing code should be fine as is. Is there more context for the change?

I'll double-check this, but a lot of the problem with tokio for us has been that it adds a significant amount of overhead and leads to negatively impacting our test results.

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/ipc/tcp_socket.rs (1)

459-493: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

New race window: connection_id briefly absent from map during write.

Removing the stream before releasing the lock and reinserting afterward fixes the "lock held across await" issue, but introduces a TOCTOU window where connection_id doesn't exist in connections:

  • A concurrent send_to_connection for the same connection_id now fails with a spurious "Connection not found" error instead of serializing as it did previously.
  • A concurrent close_connection(connection_id) called while a write is in-flight will also get "not found" (since the entry is removed), signaling failure to the caller — but the stream is silently reinserted once the write completes, effectively resurrecting a connection the caller believed was closed.

A more robust fix is to make the per-connection lock granular instead of removing from the map, e.g. Arc<Mutex<HashMap<ConnectionId, Arc<Mutex<TcpStream>>>>>. Then send_to_connection only holds the outer map lock briefly to clone the per-connection Arc<Mutex<_>>>, and locks that specific stream across the write — no removal, no spurious "not found", and other connections remain unaffected.

Sketch of per-connection lock approach
// connections: Arc<Mutex<HashMap<ConnectionId, Arc<Mutex<TcpStream>>>>>

async fn send_to_connection(&mut self, connection_id: ConnectionId, message: &Message) -> Result<()> {
    let stream_lock = {
        let conns = self.connections.lock().await;
        conns
            .get(&connection_id)
            .cloned()
            .ok_or_else(|| anyhow!("Connection {} not found", connection_id))?
    };
    let mut stream = stream_lock.lock().await;
    Self::write_message(&mut stream, message).await.map_err(anyhow::Error::from)
}
🤖 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 `@src/ipc/tcp_socket.rs` around lines 459 - 493, The current send_to_connection
flow removes the TcpStream from self.connections, creating a race where the
connection is temporarily absent and concurrent send_to_connection or
close_connection calls can see spurious "not found" errors. Change the
connection handling in TcpSocket so send_to_connection only briefly locks the
connections map to clone a per-connection handle, then locks that specific
stream across write_message without removing the entry. Update the connections
storage to use a per-connection Arc<Mutex<TcpStream>> (or equivalent) so
close_connection and other operations can coordinate safely without resurrecting
closed connections or blocking unrelated connections.

Source: Path instructions

src/ipc/unix_domain_socket.rs (1)

424-458: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Same remove/reinsert race as tcp_socket.rs.

Identical pattern here creates the same TOCTOU window: concurrent send_to_connection calls for the same connection_id will spuriously fail with "not found", and close_connection racing with an in-flight write can be defeated (stream gets reinserted after the "close" already reported failure/removed nothing). See the parallel comment in src/ipc/tcp_socket.rs (Line 459) for a suggested per-connection-lock fix (Arc<Mutex<HashMap<ConnectionId, Arc<Mutex<UnixStream>>>>>) that avoids removing entries from the map entirely.

🤖 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 `@src/ipc/unix_domain_socket.rs` around lines 424 - 458, The send_to_connection
flow in UnixDomainSocketManager has the same remove/reinsert race as the TCP
path, causing concurrent writes to fail spuriously and close_connection to lose
races with in-flight sends. Update send_to_connection and the surrounding
connection handling to keep connections in the map and lock per-connection
instead of removing the UnixStream, using the same
Arc<Mutex<HashMap<ConnectionId, Arc<Mutex<UnixStream>>>>> pattern referenced by
the tcp_socket.rs implementation so write_message no longer depends on a
temporary removal.

Source: Path instructions

🤖 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.

Outside diff comments:
In `@src/ipc/tcp_socket.rs`:
- Around line 459-493: The current send_to_connection flow removes the TcpStream
from self.connections, creating a race where the connection is temporarily
absent and concurrent send_to_connection or close_connection calls can see
spurious "not found" errors. Change the connection handling in TcpSocket so
send_to_connection only briefly locks the connections map to clone a
per-connection handle, then locks that specific stream across write_message
without removing the entry. Update the connections storage to use a
per-connection Arc<Mutex<TcpStream>> (or equivalent) so close_connection and
other operations can coordinate safely without resurrecting closed connections
or blocking unrelated connections.

In `@src/ipc/unix_domain_socket.rs`:
- Around line 424-458: The send_to_connection flow in UnixDomainSocketManager
has the same remove/reinsert race as the TCP path, causing concurrent writes to
fail spuriously and close_connection to lose races with in-flight sends. Update
send_to_connection and the surrounding connection handling to keep connections
in the map and lock per-connection instead of removing the UnixStream, using the
same Arc<Mutex<HashMap<ConnectionId, Arc<Mutex<UnixStream>>>>> pattern
referenced by the tcp_socket.rs implementation so write_message no longer
depends on a temporary removal.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Enterprise

Run ID: 96981e28-a141-41c9-b346-d3840dd4f577

📥 Commits

Reviewing files that changed from the base of the PR and between 0e34e42 and 1e063e0.

📒 Files selected for processing (2)
  • src/ipc/tcp_socket.rs
  • src/ipc/unix_domain_socket.rs

@dustinblack

Copy link
Copy Markdown
Collaborator

Good question. You're right that tokio::sync::Mutex is safe to hold across .await — it yields the task instead of blocking the executor thread, so there's no risk of starving other tasks or causing UB.

However, safe != optimal here. The issue is about lock duration, not lock safety. write_message() includes a 5-second timeout for backpressure handling. With the lock held across the write:

  • All other connections serialize behind whichever connection is currently writing. If connection 1 writes to a slow peer, connections 2-10 queue up behind the mutex even though they're writing to completely independent streams.
  • get_active_connections() uses try_lock(), so it returns empty any time a write is in progress — making the connection list unreliable under load.

The remove/re-insert pattern reduces lock hold time from "duration of network I/O" (milliseconds to seconds) to "duration of HashMap lookup" (nanoseconds). The cost is two lock acquisitions instead of one, and the connection is briefly invisible in the map during the write. Since send_to_connection has no callers today, the temporary-invisibility edge case (concurrent sends to the same connection_id) is theoretical.

In short: the existing code wouldn't deadlock or block the runtime, but it would serialize multi-client throughput unnecessarily. The fix is a net improvement.

@sberg-rh

sberg-rh commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

I guess the question for me is should this PR consider the send_to_connection case (which currently has no callers), or is that out of scope for this PR?

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.

Mutex held across await in async multi-client send_to_connection

3 participants