Skip to content

fix(network): report iOS requests that reused a keep-alive connection - #2433

Merged
thymikee merged 6 commits into
callstack:mainfrom
boorad:fix/network-dump-reused-connections
Sep 10, 2026
Merged

fix(network): report iOS requests that reused a keep-alive connection#2433
thymikee merged 6 commits into
callstack:mainfrom
boorad:fix/network-dump-reused-connections

Conversation

@boorad

@boorad boorad commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Summary

On iOS, network dump silently omitted any request that reused a keep-alive connection, so "assert this endpoint was called on startup" read as a definite fail. Closes #2441. Also the second half of #2430, whose react-devtools half is now fixed upstream in callstackincubator/agent-react-devtools#60 and tracked here by #2432, so this should not auto-close #2430.

CFNetwork logs a request URL only on the com.apple.network:connection line that opens a connection. A reused task emits a summary with status, timing and byte counts but no URL anywhere, so a URL-keyed reader dropped it. From a real simulator log, /init reusing the connection /v4/messages/en_US opened 354ms earlier:

[C9 ... url: http://localhost:3040/v4/messages/en_US] start
Task <2FAEF670>.<2> summary for task success {response_status=200, connection=9, reused=1, ...}

Such a request is now reported against its connection's origin with pathUnavailable: true, its status and its timing (raw keeps the summary, including request_bytes/response_bytes). The path is genuinely absent from the log, so it is not invented. A reused task whose connection opened before the scan window resolves to nothing at all; those are counted in the new unnamedRequests and named in the notes, so an empty dump still reports that traffic was observed. Also stops parsed URLs keeping the punctuation that follows them, so url compares equal to the endpoint under test. 7 files.

Two shaping notes: pathUnavailable overloads url with an origin (the alternative — dropping the entry — is the bug), and the correlation lives inside network-traffic.ts rather than a sibling module because capture-kit/src/index.ts may not grow its eager import closure and that gate has no approval path.

Validation

f2360bd3. pnpm check:affected --run passes on that commit; fallow clean. Full pnpm test:unit: 9438 pass; the 2 code-signature.test.ts failures reproduce on a clean tree and are unrelated.

A/B on one captured app.log from a real RN app: before, 39 entries, 1 on :3040 (carrying a trailing comma), /init absent; after, 47 entries, 2 on :3040, /init present at status=200. Live network dump on the production path reported 8 such requests plus the note. Truncating the window to 450 scanned lines counted 2 unnameable requests instead of dropping them.

Risk: assumes CFNetwork's connection=N shares numbering with the [CN] nw connection id. Every captured log is consistent with it; a divergence would attribute a request to the wrong origin, so it is worth a maintainer's eye.

@thymikee

Copy link
Copy Markdown
Member

Three cases need fixing at f2360bd. The simulator recovery path only merges results when entries are nonempty. If log show returns only reused-task summaries with no known origin, its unnamedRequests count is discarded and the response still says no HTTP traffic was found. Preserve those counts and add a recovery-only unnamed-traffic regression.

Connection correlation uses only the C number, not the process identity in the log. A reused connection number after an app restart can inherit an origin from the previous process. Scope the match to the process and connection, and test different PIDs with the same C number; unmatched traffic must stay unnamed.

The global trailing-punctuation removal changes valid URLs such as https://example.test/release. into a different endpoint. Remove separators only where the log format establishes that they are separators, and test valid trailing punctuation. The reported live evidence is useful, but these cases are not covered yet. No CI checks are reported on this head.

@thymikee

Copy link
Copy Markdown
Member

This head now also conflicts with main. The previously reported code findings still apply; please resolve those and the conflict before rerunning the affected checks.

@boorad
boorad force-pushed the fix/network-dump-reused-connections branch from f2360bd to 0bc675b Compare September 10, 2026 13:01
@boorad

boorad commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

All three fixed, rebased onto 3bbeb619 (conflict was CHANGELOG only). New head 0bc675b6. Also: this is the PR for #2441, which you filed for this half — happy to retitle if you'd rather track it there.

Recovery counts discarded. You were right that the merge was gated on entries alone. The recovery pass now merges whenever it observed traffic in either form, and the "none looked like HTTP traffic" note is reserved for a pass that found neither. Regression added: recovery returns only an unresolvable reused summary, and the response reports unnamedRequests: 1 plus the gap note instead of an empty window. It fails against the previous commit.

Cross-process origin inheritance. Agreed, and this was the worst of the three — attributing a request to a host the app never contacted is worse than dropping it. The index is now keyed on the compact log's name[pid] together with the connection number. A line whose process cannot be read correlates to nothing and its traffic stays unnamed rather than borrowing an origin. Three regressions: same C number under a different pid stays unnamed; the same number under two processes resolves to the opener; an unreadable process token stays unnamed.

Trailing punctuation. Correct, https://example.test/release. was being corrupted. Dropped the global strip. The parser now reads the delimited url: <value>, field, where the format establishes the separator, and leaves a bare URL match exactly as matched. Regressions cover both directions.

Validation. pnpm check:affected --run passes on 0bc675b6; fallow clean (extracted mergeRecoveredTraffic to keep dumpAppleNetworkTraffic under the complexity threshold). Full pnpm test:unit: 9479 pass, the 2 code-signature.test.ts failures reproduce on a clean tree and are unrelated. Re-ran the parser over the same captured simulator log to confirm the process-scoping change did not silently disable correlation: identical results, 47 entries with 8 named against their origin.

On CI: no checks report because this is a fork PR awaiting the "Approve and run" step — nothing I can trigger from my side.

@boorad

boorad commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

All three fixed and rebased onto 3bbeb619; conflict resolved. New head 0bc675b6. Also linking this to #2441, which tracks the same work — its three required behaviors are covered here.

Cross-process origin inheritance. You're right, and this was the worst of the three: attributing a request to a host the app never contacted is worse than dropping it. The index now keys on the compact log's name[pid] plus the connection number, so a relaunched app cannot inherit its predecessor's origin. A line whose process identity cannot be read correlates to nothing and its traffic stays unnamed rather than borrowing a match.

Recovery path discarding counts. Confirmed. The pass merged only when it produced entries, so a recovery window holding nothing but unnameable summaries dropped the count and the response still claimed an empty window — while also emitting "none looked like HTTP traffic", which was untrue. It now merges whenever the pass observed traffic in either form, and that note is reserved for a pass that found neither.

Trailing punctuation. Also right, and my justification was too broad. https://example.test/release. is a different endpoint after a global strip. The URL now comes from the delimited url: <value>, field where the format establishes the separator; a bare URL is left exactly as matched.

Regressions, each verified to fail without its fix (reverting the fix alone turns the test red):

  • same connection number under a different pid → stays unnamed
  • unreadable process identity → stays unnamed
  • recovery-only unnamed traffic → count preserved, misleading note absent
  • https://example.test/release. → path preserved

Re-checked against the original captured simulator log after the key change: unchanged at 47 entries / 8 reported against an origin, and a 450-line window still counts 2 unnameable requests rather than dropping them.

pnpm check:affected --run passes on 0bc675b6; pnpm test:unit 9479 pass. The 2 code-signature.test.ts failures reproduce on a clean checkout of main (macOS fs.rmSync on a symlinked dir) and are unrelated. I can't trigger your CI from a fork branch — happy to have you run it, or to move this somewhere it runs automatically.

@thymikee

Copy link
Copy Markdown
Member

The three earlier findings are fixed at 0bc675b. One merge case remains: mergeNetworkDumps uses Math.max for unnamedRequests, although the app log and recovery window can contain different requests. Two distinct summaries in one and three in the other are reported as three, while a request resolved by recovery can still be counted as unnamed from the app log. Preserve enough identity to reconcile the windows, or expose this explicitly as a lower-bound observation rather than an exact request count. Add overlapping and disjoint-window regressions. No CI checks are reported on this fork head.

@boorad

boorad commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Fixed at eef183c4, rebased onto 805ffb46.

You're right that Math.max was wrong in both directions, and the second half — a request the recovery pass resolved still being counted as unnamed from the app log — is the part I'd missed entirely.

I went with reconciliation rather than relabelling it a lower bound, because the identity is already in the log: every CFNetwork line names its request as Task <UUID>.<seq>. Scoped to the emitting process (same reason as the connection key), that identifies one request across both windows. So:

  • NetworkDump.unnamedRequests (count) becomes unnamedRequestIds (identities).
  • A merge unions the unnamed identities and subtracts anything either window managed to name.
  • A resolved reused request now carries its identity as packetId, which is what that subtraction keys on. It also gives an agent a handle to grep the raw log with.

Two unnameable requests in one window and three in the other now report five; the same request seen in both reports once; and a request named in either window is not reported as unnamed by the other.

Regressions: disjoint windows, overlapping windows, and named-in-one-window-only. The first and third fail against the old max-based merge — the middle one passes either way, since max(2,1) happens to be right there, so I've kept it as a consistency check rather than claiming it discriminates.

pnpm check:affected --run passes on eef183c4, fallow clean (I had to split a predicate out of the collector to stay under the complexity threshold). pnpm test:unit 9482 pass; the 2 code-signature.test.ts failures reproduce on a clean main and are unrelated. Re-checked against the original captured simulator log: unchanged at 47 entries / 8 reported against an origin, and the truncated-window counts still hold at 2 and 1.

Still no CI on this head — it's a fork branch, so I can't trigger it. Let me know if you'd rather I do anything to get those running.

@boorad
boorad force-pushed the fix/network-dump-reused-connections branch from 0bc675b to eef183c Compare September 10, 2026 14:32
@thymikee

Copy link
Copy Markdown
Member

The earlier classification fixes look addressed at eef183c. One output-bound issue remains: unnamedRequestIds collects every unresolved task in the scan window and is spread directly into the response, independent of maxEntries. A network dump 1 can therefore return thousands of task IDs. Keep identities internal for reconciliation and expose a count, or cap the public list with explicit truncation. Please add a regression with more unnamed tasks than the requested entry limit.

@thymikee

Copy link
Copy Markdown
Member

The local Apple route now keeps task IDs out of the response, but the fix is incomplete at a46bcd5. Limrun (app-log-runtime.ts) and WebDriver (platform-runtime.ts) still return the scanner result directly, including every unnamedRequestIds entry. An iOS network dump 1 through either provider can still return thousands of IDs. Put the public projection at the shared response boundary, or cover every producer, and add provider-route regressions. GitHub has no checks for this head.

@boorad
boorad force-pushed the fix/network-dump-reused-connections branch from a46bcd5 to 7ed6992 Compare September 10, 2026 16:48
@boorad

boorad commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Fixed at 7ed69927, rebased onto 3e89f821.

You're right, and I'd actually flagged bounded response arrays to myself earlier and then talked myself out of it on the grounds that the count would be small in practice — which is exactly the reasoning that produces an unbounded output. unnamedRequestIds was sized by the scan window, not by maxEntries, so network dump 1 could answer with thousands of ids.

Took your first option. The identities exist only to reconcile two scan windows, and that step finishes before a dump is returned, so they no longer leave the reader:

  • NetworkDump.unnamedRequests is a count again, bounded by construction.
  • The identities ride ScannedNetworkDump, an internal widening that only the reader and mergeNetworkDumps speak.
  • The Apple runtime projects them away with withoutScanIdentities on the way out.

Reconciliation is unchanged, since the merge still sees the identities and recomputes the count from them: overlapping windows still collapse to one request, disjoint windows still sum.

Regression: five unnameable tasks against maxEntries: 1 reports all five and exposes no identity list. It fails if the projection is removed.

Validation

pnpm test:unit 9483 pass. pnpm check:affected --run gets through typecheck, layering, fallow, build, vitest-related, command-docs and the rest, then fails at mutation-model.

That failure is not from this branch. scripts/mutation/ownership.test.ts → "a kernel is owned by tests that reach it indirectly" fails identically on a pristine checkout of 3e89f821 with no changes applied (4 pass / 1 fail, same assertion). It asserts over daemon-error.test.ts owning kernel-errors and gestures.test.ts owning scroll-edge-state, neither of which this branch touches. Worth a look on main independently of this PR.

Also still outstanding: the 2 code-signature.test.ts failures, likewise reproducible on a clean main (macOS fs.rmSync on a symlinked dir), and no CI on this fork head.

Real-log evidence re-checked after the change: 47 entries / 8 reported against an origin, and truncated windows still count 2 and 1 unnamed.

CFNetwork logs a request URL only on the `com.apple.network:connection`
line that opens a connection. A request that reuses a keep-alive
connection emits a task summary carrying status, timing, and byte counts
but no URL anywhere in the log, so a URL-keyed reader dropped it and the
dump silently omitted a request that did happen. An "assert this endpoint
was called on startup" check therefore read as a definite fail.

Correlate a reused task summary with the connection it names and report
it against that connection's origin, with `pathUnavailable` set, its
status, and its timing. The request path is not in the log at all, so the
dump also notes how many requests it could not name — a gap in
observation now reads as a gap rather than as a negative observation.

Also stop a URL parsed out of a log line from carrying the punctuation
that follows it, so an entry's `url` compares equal to the endpoint under
test instead of failing on a trailing comma.

The correlation lives in the reader rather than a sibling module because
`packages/capture-kit/src/index.ts` may not grow its eager import closure.

Refs callstack#2430
Review of the parent commit found the same definite-negative it fixes,
one level down: a reused task summary whose connection was opened before
the scanned window resolves to no origin, so it produced no entry and no
signal — an empty dump reporting "No HTTP(s) entries were found" for a
window that demonstrably carried traffic. Count those in the dump's
`unnamedRequests` and say so in the notes, so an unnameable request is
still a reported observation.

Also order the Apple note builders so the keep-alive note no longer trips
the `notes.length === 0` guard that suppresses lifecycle guidance, and
give the android-backend test a fixture an Apple dump would actually
resolve, so the backend gate it names is the thing it proves.
Review findings on the parent commits: three ways the reader still answers
with something other than what it observed.

A connection number is only meaningful within one process, but the index
keyed on the number alone, so an app that relaunched and reopened the same
number inherited the origin its predecessor had contacted — a request
attributed to a host it never reached, which is worse than dropping it.
Key the index by the compact log's `name[pid]` and the connection number
together; a line whose process cannot be read correlates to nothing and its
traffic stays unnamed.

The simulator recovery pass merged its dump only when it carried entries,
so a recovery window holding nothing but unnameable reused-task summaries
discarded that count and the response still reported an empty window. Merge
whenever the pass observed traffic in either form, and reserve the "none
looked like HTTP traffic" note for a pass that found neither.

The trailing-separator strip was global, so a valid URL ending in
punctuation became a different endpoint. Take the URL from the delimited
`url:` field where the format establishes the separator, and leave a bare
URL exactly as matched.

Regressions cover each: the same connection number under a different pid,
an unreadable process identity, recovery-only unnamed traffic, and a path
that legitimately ends in a period.
The app log and the simulator recovery pass cover different, sometimes
overlapping windows, so taking the larger of their two unnamed counts was
wrong in both directions: two unnameable requests in one window and three
in the other reported three rather than five, and a request the recovery
pass resolved stayed counted as unnamed from the app log.

Carry the identities instead of a count. Every CFNetwork line names its
request as `Task <UUID>.<seq>`, scoped here to the emitting process, so the
same request seen in two windows is recognisable as one. A merge unions the
unnamed identities and subtracts anything either window managed to name, and
a resolved reused request carries its identity as `packetId` so that
subtraction has something to key on.

`NetworkDump.unnamedRequests` becomes `unnamedRequestIds`, since a list of
identities is what makes the reconciliation exact rather than a lower bound.

Regressions cover disjoint windows, overlapping windows, and a request one
window named while the other could not.
`unnamedRequestIds` collected every unresolved task in the scan window and
was spread straight into the response, so `network dump 1` could answer
with thousands of task ids: an output whose size tracked the log rather
than the requested entry limit.

The identities exist to reconcile two scan windows, which is a step that
finishes before a dump is returned. Keep them there. `NetworkDump` carries
`unnamedRequests` as a count again, bounded by construction; the identities
ride `ScannedNetworkDump`, the internal widening that the reader and the
merge speak, and the Apple runtime projects them away with
`withoutScanIdentities` on the way out.

Reconciliation is unchanged: overlapping windows still collapse to one
request and disjoint windows still sum, because the merge still sees the
identities and recomputes the count from them.

Regression: five unnameable tasks against `maxEntries: 1` reports all five
and exposes no identity list.
The Apple route stopped leaking task identities into its response, but
Limrun and WebDriver return the scanner result directly and both serve
Apple sessions, so an iOS `network dump 1` through either still answered
with every unresolved task id in the scan window. Projecting at one
producer was never going to hold: `ScannedNetworkDump` was assignable to
`NetworkDump`, so returning the scanner result compiled everywhere and
each producer had to remember not to.

Take the shape away instead. `readRecentNetworkTrafficFromText` returns a
`NetworkScan` — `{ dump, unnamedRequestIds }` — so identities sit beside
the public dump rather than on it, and `mergeNetworkScans` reconciles the
pair. A route returning `scan.dump` cannot carry them out, and a route that
forgets does not compile. All four producers are updated; the response
shape is unchanged.

Regressions cover the Apple, Limrun and WebDriver routes: five unnameable
tasks against `maxEntries: 1` report the count and expose no identity list.
All three fail if the identities are put back on the dump.
@boorad
boorad force-pushed the fix/network-dump-reused-connections branch from 7ed6992 to 3fd6441 Compare September 10, 2026 17:24
@boorad

boorad commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Fixed at 3fd64413, rebased onto f4c8f3dd.

You're right, and the shape of the miss is worse than the miss: I'd enumerated all four producers before the previous round, fixed one, and treated it as done. Projecting at a single producer could never have held anyway — ScannedNetworkDump was assignable to NetworkDump, so returning the scanner result compiled everywhere and every producer had to remember not to.

So I took the shape away rather than covering the routes one by one. readRecentNetworkTrafficFromText now returns a NetworkScan{ dump, unnamedRequestIds } — with the identities beside the public dump rather than on it, and mergeNetworkScans reconciles the pair. A route returning scan.dump cannot carry identities out, and a route that forgets does not compile. All four producers (Apple, Android, Limrun, WebDriver) are updated; the response shape is unchanged, and reconciliation still behaves as before.

Regressions on all three Apple-capable routes — Apple, Limrun (ios-simulator) and WebDriver (ios-device) — five unnameable tasks against maxEntries: 1, asserting the count is reported and no identity list is exposed. All three fail if the identities are put back on the dump; I checked by reintroducing the leak.

Validation

pnpm check:affected --run passes fully on 3fd64413, pnpm test:unit 9517 pass.

I have to correct my last comment on one point. I reported check:affected failing at mutation-model and suggested main was broken. The gate failure was mine: my fork's main was 60 commits stale, and several checks derive their base from origin/main, so the affected set was enormous and test-file-size-ratchet was even attributing an upstream test file's growth to this branch. I've synced the fork, and the gate is green.

The underlying observation does survive, though, so it may still be worth a look independently: pnpm mutation:test fails 1/40 on a pristine checkout of f4c8f3dd with a synced base — scripts/mutation/ownership.test.ts → "a kernel is owned by tests that reach it indirectly". It is simply not selected for this branch's change set, so it no longer gates this PR.

Remaining, both reproducible on a clean main: the 2 code-signature.test.ts symlink failures, and no CI on this fork head.

@thymikee

Copy link
Copy Markdown
Member

The output-bound issue is fixed at 3fd6441. Scan identities are separate from the public dump, and the Apple, Limrun and WebDriver regressions cover the affected routes. No remaining code findings; the reported local checks pass, but GitHub still has no checks on this fork head, so please approve and run CI before merging.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 10, 2026
@boorad

boorad commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for running it — CI is green on 3fd64413: 12 passed, 3 skipped (Bundle Size, deploy-preview, matrix.name), nothing failing. That includes Integration Tests and Coverage, which you'd rightly said a local unit run doesn't establish, plus Repo Guards.

Nothing outstanding from my side. The branch is rebased on f4c8f3dd and mergeable.

@thymikee
thymikee merged commit f57b421 into callstack:main Sep 10, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

2 participants