Conversation
There was a problem hiding this comment.
Pull request overview
Adds an extension → BrowserManager reply path on the existing extension control socket, and uses it to make FinalizeCommand wait for an explicit FinalizeAck instead of sleeping a fixed interval.
Changes:
- Add framed response receiving on the Python
ClientSocketand wait for an extension ack inFinalizeCommand. - Extend the privileged WebExtension sockets API to track accepted connections, include
connectionIdinonDataReceived, and addsendResponse(...). - Plumb a per-message
respond(...)callback throughExtension/src/socket.tsand sendFinalizeAckfromloggingdb.ts.
Reviewed changes
Copilot reviewed 6 out of 7 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| openwpm/socket_interface.py | Adds ClientSocket.receive() for framed replies from the extension. |
| openwpm/commands/browser_commands.py | Updates FinalizeCommand to wait for a FinalizeAck with a bounded timeout. |
| Extension/src/types/browser.d.ts | Updates TS types for connectionId and sendResponse. |
| Extension/src/socket.ts | Passes a bound respond function to per-socket listeners. |
| Extension/src/loggingdb.ts | Replies with FinalizeAck on Finalize. |
| Extension/bundled/privileged/sockets/schema.json | Extends schema for sendResponse and adds connectionId to the event. |
| Extension/bundled/privileged/sockets/api.js | Implements connection tracking and sendResponse in the privileged sockets API. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #1174 +/- ##
==========================================
+ Coverage 62.36% 62.40% +0.04%
==========================================
Files 40 40
Lines 3930 3993 +63
==========================================
+ Hits 2451 2492 +41
- Misses 1479 1501 +22 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
vringar
force-pushed
the
feat/extension-finalize-ack
branch
from
May 17, 2026 20:40
6c457c3 to
749d65e
Compare
vringar
force-pushed
the
feat/extension-finalize-ack
branch
from
June 19, 2026 23:24
749d65e to
0729d58
Compare
This was referenced Jun 20, 2026
vringar
added a commit
that referenced
this pull request
Jun 21, 2026
vringar
force-pushed
the
feat/extension-finalize-ack
branch
2 times, most recently
from
July 21, 2026 10:42
43802b2 to
c63e425
Compare
vringar
force-pushed
the
feat/extension-finalize-ack
branch
from
July 27, 2026 10:55
c63e425 to
629e163
Compare
Adds a bidirectional channel on the extension control socket so the WebExtension can reply to BrowserManager, and uses it to replace the fixed pre-Finalize sleep with an explicit FinalizeAck. Previously FinalizeCommand slept a fixed 5s before sending Finalize to let in-flight events drain, then cleared visit state with no confirmation. The control socket was send-only, so BrowserManager could not tell whether the extension had actually finished. Privileged socket API (Extension/bundled/privileged/sockets): - Track accepted connections in connectionMap; sendResponse() writes a framed reply on the originating connection. - onDataReceived now also passes the connectionId to listeners. - The reply output stream is opened blocking, mirroring the outbound socket, and is closed on disconnect instead of leaking. - One writeFramedMessage() helper is shared by sendData and sendResponse. Extension plumbing (socket.ts, loggingdb.ts): - DataReceiver builds a connection-bound respond() and passes it to the callback. - On Finalize the extension keeps visit_id set for a grace period passed by BrowserManager so stragglers are still attributed, then flushes meta_information, clears the visit, and replies FinalizeAck. The grace wait now runs in the event loop that drains those stragglers. Python side (socket_interface.py, browser_commands.py): - ClientSocket.receive() reads a framed reply via an internal buffer so a mid-frame timeout cannot desync the protocol, and restores any prior socket timeout. - FinalizeCommand sends the grace duration and blocks on a matching FinalizeAck, bounded by grace + FINALIZE_ACK_MARGIN. On FinalizeAck timeout the visit is marked unsuccessful (finalize_visit_id success=False) and the failure counter is incremented, without forcing a browser restart. A merely-slow drain is not a crash, so it should not pay the restart cost. The timeout raises a dedicated FinalizeAckTimeout, which the browser process reports as a new FINALIZE_INCOMPLETE status; the parent maps it to a finalize_incomplete command status that marks and counts the visit but leaves the browser running. Adds test/test_socket_interface.py covering the framing edge cases and test/test_finalize_ack.py covering the FinalizeAck handshake (ack round-trip returns cleanly; a missing ack raises FinalizeAckTimeout).
vringar
force-pushed
the
feat/extension-finalize-ack
branch
from
August 2, 2026 10:11
629e163 to
c7f760f
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a bidirectional channel on the existing extension control socket so the
WebExtension can send responses back to
BrowserManager, and uses it toreplace
FinalizeCommand's fixed pre-Finalizesleep with an explicitFinalizeAckfrom the extension.This is the extension→BrowserManager back-channel discussed as option (b)
under #1172 / #1173: a general-purpose reply path a follow-up can reuse to
route JS instrumentation failures (and other extension-side signals) directly
to
BrowserManager.Background
The control socket (
Initialize/Finalize, Python → extension) wassend-only. Per-visit teardown worked like this:
The
sleepexists because closing the tab does not immediately stop datacollection — events captured just before teardown are still in flight
(content-script→background messaging, late
webRequestcallbacks). The sleepruns before
Finalize, sovisit_idstays set in the extension during itand those stragglers are still attributed to the correct visit. After the
sleep,
Finalizeclearsvisit_id.Two problems: it always costs the full fixed interval, and
BrowserManagergets no confirmation that the extension actually finished.
Why the first cut of this PR was wrong
The initial implementation sent
Finalizeimmediately (no grace) and hadthe extension clear
visit_idand ack right away. That ack only confirmed thecontrol-socket round trip — it did not wait for stragglers, and by
removing the grace it made any event arriving after
Finalizeget taggedvisit_id = -1. For a measurement framework that is a worse regression thanthe slowness it set out to fix. The redesign below keeps the grace's
guarantee.
Design
The grace period moves into the extension, where the stragglers actually
are. On
Finalize, the extension keepsvisit_idset, waits out a graceperiod, then flushes
meta_information, clears the visit, and only thenreplies
FinalizeAck.BrowserManagerpasses the grace duration in theFinalizemessage and blocks on the ack.This is strictly better than the old Python-side sleep: the wait now happens
inside the JS event loop that drains the in-flight events, instead of in an
idle Python
time.sleep()in a different process. The ack is now meaningful —it means "this visit is fully finalized and handed off to storage" — which is
exactly the signal the #1173 follow-up needs. Behaviour for real crawls is
unchanged (same grace, same attribution); the test suite uses a short grace.
Ack-timeout handling
Because a visit is now considered done only once its
FinalizeAckarrives, thetimeout path carries real meaning: if the extension does not reply within the
grace + FINALIZE_ACK_MARGINwindow, the visit's data may be incomplete.Rather than silently proceeding (which would leave an incomplete visit
indistinguishable from a clean one in the data), the timeout is now surfaced as
a distinct, non-fatal outcome:
FinalizeCommandraisesFinalizeAckTimeoutwhen the deadline is hit.BrowserManagermaps that to a dedicatedFINALIZE_INCOMPLETEcommandstatus and, on it, marks the visit unsuccessful
(
finalize_visit_id(success=False)) and incrementsfailure_count— butdoes not set
restart_required. A merely-slow drain is not a crash andshouldn't pay the browser-restart cost.
run of ack timeouts trips the normal
ExceedCommandFailureLimitabort ratherthan being ignored.
This keeps the crawl moving on a one-off slow finalize while ensuring an
unacknowledged visit is recorded as unsuccessful and counted, not silently
passed. The
crawl_historyrow for theFinalizeCommandrecords thefinalize_incompletestatus, so the timeout is auditable after the fact.The command-level timeout for
FinalizeCommandis derived from the sameconstant as the internal blocking bound and is kept strictly greater than it,
so the manager watchdog cannot kill a browser that is healthily waiting out its
own ack window.
Changes
Privileged socket API (
Extension/bundled/privileged/sockets/{api.js,schema.json})connectionMap(connectionId → streams).sockets.sendResponse(connectionId, data, json)writes a framed reply onthe originating connection.
sockets.onDataReceivedalso passes theconnectionIdto listeners.OPEN_BLOCKING), and isclosed on disconnect rather than leaked.
sendDataandsendResponsenow share onewriteFramedMessage()helper.Extension plumbing (
Extension/src/{socket.ts,loggingdb.ts,types/browser.d.ts})DataReceiver.onDataReceivedbuilds a connection-boundrespond()closureand passes it to the callback.
Finalize,loggingdbwaits the BrowserManager-supplied grace (duringwhich
visit_idstays valid), then sendsmeta_information, clears thevisit, and replies
FinalizeAck.Python side (
openwpm/socket_interface.py,openwpm/commands/browser_commands.py,openwpm/browser_manager.py)ClientSocket.receive(timeout)reads a framed reply. It buffers bytes onthe instance, so a mid-frame timeout cannot desync the protocol, and it
restores any socket timeout that was set before the call.
FinalizeCommandsends the grace duration, then waits for aFinalizeAckwhose
actionandvisit_idmatch the current visit, with a boundedfallback timeout. On timeout it raises
FinalizeAckTimeoutinstead oflogging and proceeding.
BrowserManagercatchesFinalizeAckTimeoutand emits a newFINALIZE_INCOMPLETEstatus, which the command loop handles by marking thevisit unsuccessful and incrementing
failure_countwithout requiring abrowser restart (see Ack-timeout handling above).
Tests (
test/test_socket_interface.py,test/test_finalize_ack.py)pyonlyunit tests forClientSocket.receiveframing: single frame,two frames in one read, mid-frame timeout without desync, timeout
restoration, peer-close, and byte-trickled reassembly.
test/test_finalize_ack.py(pyonly):FinalizeCommandsends the graceand
visit_id; a matchingFinalizeAckreturns normally; a timeout raisesFinalizeAckTimeout(and does so after roughly the deadline); anon-matching control message is discarded before a subsequent valid ack is
accepted.
How the Copilot review comments were addressed
receive()resets socket timeout toNone, clobbering any prior timeoutreceive()now capturesgettimeout()and restores it infinally. Test:test_receive_restores_prior_socket_timeout.ClientSocketnow keeps an internal_recv_buffer; partial reads and over-reads are preserved across calls. Test:test_partial_frame_timeout_does_not_desync.FinalizeCommandcatches all exceptions and logs them as a timeoutsocket.timeout(the timeout path, which now raisesFinalizeAckTimeoutso the visit is marked unsuccessful) vs.(RuntimeError, ValueError, struct.error)(logged withlogger.exception).FinalizeCommanddoesn't validate the ack matches the current visitaction == "FinalizeAck"andvisit_id == self.visit_id, discarding (and logging) anything else, bounded by a shared deadline.FinalizeAcksent before late records are flushed /visit_idcleared too earlyvisit_idstays valid through it, and the ack is sent only aftermeta_informationis handed to storage.connectionMapentry is dropped.sendResponseframes bydata.length(string length, not bytes)Critical review against Mozilla socket practices
I reviewed the privileged socket code against Mozilla's
nsITransport/nsISocketTransportcontract (via Searchfox) and against OpenWPM's ownestablished socket patterns.
transport.openOutputStream(0, 0, 0). PernsITransport.idl,flags
0yields a non-blocking stream whosewrite()can throwNS_BASE_STREAM_WOULD_BLOCK— which the surroundingtry/catchwouldsilently swallow, dropping the ack. Correctly handling a non-blocking output
stream means
nsIAsyncOutputStream+asyncWaitand a write queue. Sincethe existing outbound stream in
connect()already usesopenOutputStream(OPEN_BLOCKING, …)and the ack is a tiny payload to alocalhost peer that is actively reading, a blocking write returns
effectively instantly. The reply stream now uses
OPEN_BLOCKING, matchingthe existing pattern; the value (
1 << 0) is confirmed bynsITransport.idl.an output stream from the single
transporthanded toonSocketAcceptedisthe supported pattern for full-duplex use; the input stream stays
non-blocking (required for
asyncWait) and the two streams are independent.nsIServerSocketaccepted connections own theirstreams; nothing closes them implicitly. The disconnect path now closes the
output stream (the input stream is already closed by the peer at that
point), preventing a per-visit-failure descriptor leak.
sendDataand the Pythonsocket_interfaceboth frame with a 4-byte big-endian length + 1-byteserialization tag.
sendResponsereuses that exact format (now via a sharedwriteFramedMessagehelper) so the Pythonreceive()andsend()staysymmetric.
Known limitation (documented, not silently shipped)
writeFramedMessagederives the length prefix fromdata.length, a UTF-16code-unit count, which equals the byte count only for ASCII. This is the
pre-existing framing already used by
sendData. It is correct for everymessage that travels this socket today — storage records (ASCII-escaped JSON)
and the control messages
Initialize/Finalize/FinalizeAck(stringaction + integer
visit_id+ boolean) are all ASCII. Making the framingUTF-8-safe is worthwhile, but it must change
sendDataandsendResponsetogether to keep the two senders consistent, so it is intentionally left
as a separate follow-up rather than introducing a divergence here. The
writeFramedMessagedoc comment records this.Sources referenced
netwerk/base/nsITransport.idl(Searchfox, mozilla-central) — confirmedOPEN_BLOCKING = 1<<0/OPEN_UNBUFFERED = 1<<1and that flags0producesa non-blocking stream. This is what drove switching the reply stream to
OPEN_BLOCKINGand rejecting the originalopenOutputStream(0,0,0).api.jsconnect()/sendData,socket_interface.pysend/_handle_conn/_parse) — used as thereference for stream flags, the wire format, and the serialization tags.
This is why
sendResponsemirrorssendData(shared helper, blockingstream) instead of introducing a parallel convention, and why the
byte-length question is treated as a cross-cutting follow-up.
git logonbrowser_commands.py— traced thetime.sleepgrace toe338bb2("Command refactoring", Command refactoring #750) andFinalizeCommand(sleep=5)incommand_sequence.py. Establishing that the grace keepsvisit_idvalid forin-flight events is what exposed the original PR's attribution regression and
shaped the "move the grace into the extension" design.
consumer of this back-channel, keeping
sendResponse/respond()genericrather than
FinalizeAck-specific.Follow-up
BrowserManagerhandoff;two other teardown points (the log-socket shutdown
sleep(3)inmp_loggerand the storage-controller completion-queue poll) still use fixed timeouts
and are tracked there for the same drain-and-confirm treatment.
Test plan
pytest -m pyonly test/test_socket_interface.py— 6 newpyonlyframing tests pass.pytest -m pyonly test/test_finalize_ack.py— 5pyonlytests pass (grace/visit_idmessage, ack returns normally, timeout raisesFinalizeAckTimeout, deadline timing, stale-message discard).pytest test/test_simple_commands.py::test_get_site_visits_table_validand
::test_get_http_tables_valid— full visit cycle incl. theFinalizeAckround trip, pass (headless + xvfb).pytest test/test_js_instrument.py -k "not failure"— 6 tests pass;exercises async JS events, the most likely stragglers, attributed correctly.
cd Extension && npm run build && npm run lint— clean.pre-commit run— clean on changed Python files.