fix: harden sharkd replay sessions and serve the uncapped frame list

Review-driven session/transport fixes (each reproduced live against
sharkd 4.6.7 before fixing):

- raise the RPC stream limit to 16 MB: a full 1000-row frames page
  measures ~190 KB against the 64 KB StreamReader default, which failed
  the request with a 500 and desynchronized the resident session; a
  line-over-limit ValueError is now treated as a transport failure
- verify JSON-RPC reply ids: a timed-out request's late reply was
  served as the next request's answer; timeouts, dead pipes, malformed
  and stale replies now kill the session for good instead
- make check-spawn atomic under one manager lock: concurrent requests
  for the same pcap double-spawned sharkd and leaked the loser (process
  plus /tmp scratch copy) forever
- refcount sessions and evict idle only (LRU, cap raised 8 -> 16): a
  tag with more sources than the cap respawned every source on every
  request, and concurrent requests could get their session killed
  mid-RPC (spurious 502)
- map FilterError to sharkd's filter rejection (-13002) only; other
  engine failures with a filter set are 502, not a client 400
- detail: accept an optional frame_number to disambiguate
  same-microsecond frames (ts is not unique within a pcap); drop the
  -8003 -> 404 mapping (the range is validated locally, engine errors
  are real faults); a failed hex read is a 404 instead of "hex": null
- a pcap deleted mid-request is a 404, not a 500; the pcap-sized
  scratch copy runs off the event loop; server shutdown kills every
  resident session and drops its scratch directory
- pin the packet-list layout through scratch-HOME Wireshark
  preferences: the column indexes are a contract the server owns
  (protocol-level column negotiation is rejected by sharkd 4.6.x)

Range contract change (WebUI moved to an always-flat list): the merged
frame list is returned in full, deliberately uncapped - truncated and
per-second buckets are removed, frame_count always equals
len(frames), and rendering cost is the client's concern (the window
endpoint remains the incremental path).
This commit is contained in:
YueGuobin committed 2026-09-11 00:55:18 +08:00
1 parent ef5d81498a
commit 5d91ca0efc
6 files changed
+627 -211

No files matched your search

@@ -141,7 +141,6 @@ class TestReplayRoutes:
assert body["frame_count"] == 4
assert body["start"] == "1693472000.500000"
assert body["end"] == "1693472002.000000"
assert body["truncated"] is False
assert [f["node_id"] for f in body["frames"]] == ["n1", "n2", "n1", "n2"]
assert [f["ts"] for f in body["frames"]] == [
"1693472000.500000", "1693472001.000000",
@@ -339,6 +338,36 @@ class TestReplayRoutes:
assert ttl["filter_expr"] == "ip.ttl == 64"
assert ttl["pos"] == 22 and ttl["size"] == 1
@sharkd_present
async def test_detail_frame_number_disambiguates_same_ts(
self, app: FastAPI, client: AsyncClient, project: Project, no_residual_sessions
) -> None:
# Two frames in the same microsecond: only the explicit frame number
# (from the frame list entry) tells them apart.
link = _add_marker(project, tag=7, enabled=False, node_id="n1", frames=[
(1693472000, 123456, _icmp_frame()),
(1693472000, 123456, _tcp_syn_frame()),
])
common = {"ts": "1693472000.123456", "node_id": "n1",
"link_id": link.id, "marker": "icmp"}
response = await client.get(
app.url_path_for("replay_tag_frame_detail", project_id=project.id, tag=7),
params=common,
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["source"]["frame_number"] == 1
assert response.json()["hex"] == _icmp_frame().hex()
response = await client.get(
app.url_path_for("replay_tag_frame_detail", project_id=project.id, tag=7),
params={**common, "frame_number": 2},
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["source"]["frame_number"] == 2
assert response.json()["hex"] == _tcp_syn_frame().hex()
@sharkd_present
async def test_detail_501_when_sharkd_disappears(
self, app: FastAPI, client: AsyncClient, project: Project