diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index 5a5f75ef42..4cd975ff99 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -820,7 +820,7 @@ by null; there is no single events pointer shared by all traces. | Original event and loss declaration | Observed endpoint report; `recovery:packet_lost` does not prove actual network loss. Missing packet number/type remains unknown. | | STREAM ranges and byte totals | Derived per trace and direction from reported offsets and lengths using exact integer arithmetic. `unique_bytes` unions intervals; `frame_length_sum` includes overlaps; `repeated_bytes` is their difference. Overlap alone does not prove retransmission or equal contents. | | FIN final sizes | Reported offset plus length where FIN is true; multiple conflicting sizes remain visible. A final size does not establish complete coverage or a received body. | -| Incomplete fact records | Ordinals with omitted/redacted interpretable frame metadata; usable fields in other records survive. An empty list does not establish complete logging or capture. | +| Incomplete fact records | Ordinals with omitted/redacted interpretable frame metadata, including an unknown STREAM FIN; usable fields such as known ranges in that record survive. An empty list does not establish complete logging or capture. | | HTTP bodies and causality | `body_bytes: "not-reported"`; lengths and headers are metadata, with no reconstructed body, hash, timing causality or live process attribution. | QUIC integer fields interpreted here accept exact nonnegative JSON integer diff --git a/src/browser/history/QlogCapture.test.ts b/src/browser/history/QlogCapture.test.ts index 2b2942b2e6..3b99c2e758 100644 --- a/src/browser/history/QlogCapture.test.ts +++ b/src/browser/history/QlogCapture.test.ts @@ -134,6 +134,38 @@ it("keeps incomplete fields unknown and FIN without bytes as metadata", () => { expect(traces[0]?.loss_declarations[0]?.packet_number.state).toBe("unknown"); }); +it.each([ + { fin: undefined, incomplete: [0] }, + { fin: null, incomplete: [0] }, + { fin: false, incomplete: [] }, + { fin: true, incomplete: [] }, +])( + "retains STREAM ranges and marks unknown FIN records incomplete: $fin", + ({ fin, incomplete }) => { + const streamFrame: Record = { + frame_type: "stream", + stream_id: 4, + offset: 8, + length: 3, + }; + if (fin !== undefined) streamFrame.fin = fin; + const { traces } = facts(capture([event([streamFrame])])); + expect(traces[0]?.incomplete_fact_records).toEqual(incomplete); + expect(traces[0]?.streams[0]?.contributions).toEqual([ + { + record_ordinal: 0, + frame_index: 0, + offset: "8", + length: "3", + fin: typeof fin === "boolean" ? fin : null, + }, + ]); + expect(traces[0]?.streams[0]?.covered_ranges).toEqual([ + { start: "8", end: "11" }, + ]); + }, +); + it("excludes structural auth material and explicit normalized values without guessing extension secrecy", () => { const text = capture([ { diff --git a/src/browser/history/QlogCapture.ts b/src/browser/history/QlogCapture.ts index 245ec1fd85..f740fbd52f 100644 --- a/src/browser/history/QlogCapture.ts +++ b/src/browser/history/QlogCapture.ts @@ -312,6 +312,8 @@ const collectTransportFacts = ( length: length.toString(), fin: typeof frame.fin === "boolean" ? frame.fin : null, }); + if (typeof frame.fin !== "boolean") + fact.incomplete_fact_records.push(ordinal); } }; diff --git a/tests/acceptance/tools/qlogCapture.test.ts b/tests/acceptance/tools/qlogCapture.test.ts index eafaf0ad4f..3691c685dc 100644 --- a/tests/acceptance/tools/qlogCapture.test.ts +++ b/tests/acceptance/tools/qlogCapture.test.ts @@ -29,9 +29,7 @@ const event = { time: 1791819895000.25, data: { header: { packet_type: "1RTT", packet_number: 5 }, - frames: [ - { frame_type: "stream", stream_id: 0, offset: 10, length: 5, fin: true }, - ], + frames: [{ frame_type: "stream", stream_id: 0, offset: 10, length: 5 }], }, }; const qlog = (events: unknown[]) => @@ -60,7 +58,7 @@ const assertAdditionalQlogFacets = async ( { kind: "qlog", facet: "loss-declarations", trace_index: 0 }, [{ record_ordinal: 1, packet_number: { state: "reported", value: "5" } }], ], - [{ kind: "qlog", facet: "incomplete-records", trace_index: 0 }, []], + [{ kind: "qlog", facet: "incomplete-records", trace_index: 0 }, [0]], [ { kind: "qlog", @@ -79,7 +77,7 @@ const assertAdditionalQlogFacets = async ( stream_id: "0", direction: "sent", }, - ["15"], + [], ], [{ kind: "qlog", facet: "traces", offset: 99 }, []], ] as const) { @@ -206,7 +204,15 @@ it("pages original trace-scoped facts after source deletion without conflating s ); expect(contribution.normalized_result).toMatchObject({ page: { - items: [{ record_ordinal: 0, frame_index: 0, offset: "10", length: "5" }], + items: [ + { + record_ordinal: 0, + frame_index: 0, + offset: "10", + length: "5", + fin: null, + }, + ], }, coverage: { total: 1, exhausted: true }, });