Skip to content

fix(extension): storage race fixes, mutex helper, and hardening sweep - #12

Merged
DYAI2025 merged 15 commits into
mainfrom
feat/extension-fix-and-hardening
Apr 30, 2026
Merged

DYAI2025 merged 15 commits into
mainfrom
feat/extension-fix-and-hardening

Conversation

@DYAI2025

@DYAI2025 DYAI2025 commented Apr 30, 2026 •

Copy link
Copy Markdown
Owner

Summary

Builds on PR #10 (already merged) and PR #11 (Codex review). Fixes 7 code-review findings (C1–C3, H1, M1, M2, L1) plus 5 connected read-modify-write races discovered by a codebase-wide audit after the initial review.

Code-review findings fixed (from review of #10)

ID Severity Fix
C1 Critical Serialize queue enqueue/drainHead/returnHead under a per-instance mutex
C2 Critical Serialize dedup isFresh under a per-instance mutex
C3 Critical retryNow returns all un-sent batches (failed + untried) to queue on auth_error
H1 High Remove wildcard https://*/* host_permission and unused activeTab
M1 Medium Byte-cap on queue (QUEUE_MAX_BYTES = 8 MB) prevents chrome.storage.session overflow
M2 Medium Drop activeTab (already removed as part of H1)
L1 Low Diagnostic export click handler wrapped in try/catch

Connected-race sweep (5 additional fixes)

After fixing C1/C2, an audit (grep -nE "await.*\.(get|query)\(") found the same anti-pattern in four more places:

Commit Race site
e31c97d Extract createMutex() to src/lib/mutex.js; rewire queue.js (5 sites) and dedup.js (2 sites)
4af5974 Router attempt counter (incrementAttempt / resetAttempt)
d4a8c10 saveConfig — module-level saveMutex
e3b1a43 Heartbeat counter — bumpHeartbeatCount + alarm read-runHeartbeat-write chain
3ee9331 acceptBatch now enqueues before clearRetry() on auth_error (consistency with retryNow)

New tests

  • tests/lib/mutex.test.js — ordering, error-recovery, return-value forwarding
  • tests/integration/heartbeat-race.test.js — 100 concurrent bumps with/without mutex (validates mock race fidelity)
  • Additional router tests covering auth_error batch-preservation in both acceptBatch and retryNow

CI

106 tests, 99.17% statement coverage, 91.92% branch coverage ✅ (npm run ci — lint + typecheck + manifest + tests + coverage)

Test plan

  • cd extension && npm run ci — all green
  • Load unpacked in Chrome 122+, open WhatsApp Web, verify extension captures and forwards messages
  • Set invalid API key, verify queued messages survive and replay after fix

Summary by Sourcery

Harden extension storage interactions against race conditions using a shared mutex helper, align router auth_error handling, and tighten manifest permissions while adding targeted tests and docs to cover the changes.

New Features:

  • Introduce a reusable async mutex helper for serializing per-resource operations across the extension.
  • Add a byte-based capacity limit to the send queue alongside the existing item-count cap.

Bug Fixes:

  • Fix multiple read-modify-write races in the queue, deduplication, router attempt counter, config saving, and heartbeat counters by serializing access through mutexes.
  • Ensure router retryNow and acceptBatch preserve appropriate batches in the queue on auth_error so they can be replayed after the user fixes credentials.
  • Prevent diagnostic export failures in the popup from failing silently by surfacing errors to the user.

Enhancements:

  • Refine router behavior so auth_error handling is consistent between normal sends and manual retries.
  • Clarify storage behavior and limits in the extension README.
  • Extract the lock pattern into a dedicated mutex utility to simplify and centralize concurrency control logic.

Documentation:

  • Add detailed implementation plans documenting the connected race-condition sweep and PR bug-fix sweep.

Tests:

  • Add unit tests for the mutex helper, queue byte-cap behavior, and concurrency behavior of queue and dedup under load.
  • Extend router tests to cover auth_error batch preservation semantics and concurrent retry attempt counting.
  • Add an integration test to validate heartbeat counter races and storage mock fidelity.
  • Add manifest invariant tests to enforce minimal permissions and the absence of wildcard host permissions.

BenPerro and others added 15 commits April 30, 2026 22:27
Two concurrent enqueue or drainHead calls could race on the
`read → mutate → write` pattern and lose messages — the exact failure
mode the durable queue exists to prevent. Add a per-instance promise-chain
lock so every mutating op runs to completion before the next starts.

Two new concurrency tests assert no item loss under 100 parallel enqueues
and overlapping drainHead calls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two simultaneous isFresh(id) calls could both pass the includes() check
before either wrote, returning true twice for the same id. Same lock
pattern as queue.js.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…or (C3)

drainHead(10) removed up to 10 batches; if the first hit 401 the rest
were silently dropped. Now we slice the unprocessed tail back into the
queue head before returning auth_error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The mutex tail variable was inferred as Promise<void> from the initial
Promise.resolve(). After next.catch() widens the type, tsc rejected the
reassignment under --checkJs. Pin tail as Promise<unknown>.

No behavior change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Follow-up to C3 (commit 2ff8502). The fix correctly preserved untried
batches on auth_error but still lost batches that had accumulated in the
`failed` array via earlier network/server errors in the same drain pass.
Now both `failed` and untried tail are returned. The auth_error batch
itself stays dropped (consistent with acceptBatch's auth_error contract).

New test covers the mixed-failure scenario: 3 batches queued, first two
hit 503 (failed), third hits 401. Queue must retain 2 batches afterward.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…1 + M2)

H1: https://*/* granted blanket access to every https origin. The
extension only needs the user-configured server (localhost is preserved
for dev; users with a remote server must add their host explicitly or
grant via a future optional_host_permissions flow).

M2: activeTab was unused — the content script auto-injects via
content_scripts matches and the popup makes no use of executeScript.

Both invariants now pinned in tests/manifest.test.js.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The 200-batch count cap was sized for text-only messages (~500 B each);
audio messages are ~100 KB base64. Adds a maxBytes option (8 MB in
production) that evicts oldest batches when the serialized total exceeds
budget, alongside the count cap. Whichever cap triggers first wins.

README storage map updated to describe the dual cap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
If collectDiagnostics or the Blob/URL APIs throw, the rejection was
unhandled and the user saw nothing happen. Now surfaces the failure via
the status line.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Captures the implementation plan for the 7 review findings landed in
this branch (commits 7bbc007..97d5d80) for future reference.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The inline promise-chain lock pattern from C1/C2 is about to land in three
more places (attempt counter, saveConfig, heartbeat counts). Five callers
clears the DRY threshold — extract createMutex() to src/lib/mutex.js and
re-wire queue.js and dedup.js to use it. No behavior change.

3 new mutex unit tests cover ordering, error recovery, and return-value
forwarding. Existing concurrency tests for queue and dedup still pass
unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
incrementAttempt did read → +1 → write non-atomically. Two concurrent
retryNow calls (e.g., manual FLUSH_QUEUE colliding with the retry alarm)
could both read N and both write N+1, losing one increment and corrupting
the exponential backoff ladder.

Move getAttempt / incrementAttempt / resetAttempt into createRouter as
closures over a per-instance mutex. New regression test asserts that 3
parallel retryNow calls produce a counter ≤ 3 (no double-counting and no
lost increments).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two parallel saveConfig calls with patches to different fields could
both loadConfig(), both merge their own patch into the freshly-read
state, both write — losing whichever write committed first. Realistic
trigger: user toggles capture-enabled while a separate add-contact is
in flight.

New regression test pins that two patches touching independent fields
must compose. saveConfig now exports as a non-async function whose body
runs inside a module-level mutex; behavior is otherwise identical.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
bumpHeartbeatCount did read → +1 → write non-atomically against
chrome.storage.local. The heartbeat alarm handler was even worse: it
read counts, ran runHeartbeat (parallel network I/O across all chats,
multiple seconds), then wrote result.remaining — obliterating every
bumpHeartbeatCount increment that landed during the send window.

Wrap both the bump and the alarm handler's read-then-write region in a
shared module mutex. The alarm handler now holds the lock across
runHeartbeat — a multi-second hold by design — so bumps queue cleanly
behind it instead of getting eaten.

New integration test pins the contract: 100 concurrent bumps must land
100 counts. A sanity-check sibling test confirms the unprotected version
really does lose increments under the chrome.storage mock.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…y with retryNow)

retryNow (post-C3) returns failed and untried batches to the queue on
auth_error so they replay once the user fixes the key. acceptBatch did
the opposite — on 401 it returned 'rejected' and the batch was silently
dropped. Same root state, opposite outcome. Align: acceptBatch now
enqueues the batch and clears the retry alarm, leaving the user to
trigger a manual flush after the fix.

Two test updates: the existing 'drops a batch on auth_error' is renamed
to 'preserves the batch' and now asserts queueSize === 1; a new test
confirms acceptBatch behaves identically to retryNow's auth_error path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 30, 2026 22:03
@sourcery-ai

sourcery-ai Bot commented Apr 30, 2026 •

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Introduces a reusable async mutex helper and applies it across storage-backed components (queue, dedup, router attempt counter, config, heartbeat) to eliminate read–modify–write races; hardens auth_error handling in the router so batches are preserved for replay; adds a byte cap to the send queue; tightens extension permissions and improves diagnostic UX; plus adds targeted tests and docs/plans to lock in the new concurrency and behavior contracts.

Sequence diagram for heartbeat bump and alarm handling with mutex

sequenceDiagram
  participant Background as BackgroundWorker
  participant HeartbeatMutex as HeartbeatMutex
  participant StorageLocal as StorageLocal
  participant Server as HeartbeatServer

  rect rgb(230,230,250)
    Background->>HeartbeatMutex: bumpHeartbeatCount(chatId)
    activate HeartbeatMutex
    HeartbeatMutex->>StorageLocal: get(HEARTBEAT_COUNTS_KEY)
    StorageLocal-->>HeartbeatMutex: counts
    HeartbeatMutex->>StorageLocal: set(updatedCounts)
    deactivate HeartbeatMutex
  end

  Note over Background,Server: Later, heartbeat alarm fires

  Background->>HeartbeatMutex: onHeartbeatAlarm()
  activate HeartbeatMutex
  HeartbeatMutex->>StorageLocal: get(HEARTBEAT_COUNTS_KEY)
  StorageLocal-->>HeartbeatMutex: counts
  HeartbeatMutex->>Server: runHeartbeat(serverUrl, apiKey, counts, queueSize)
  Server-->>HeartbeatMutex: result.remaining
  HeartbeatMutex->>StorageLocal: set({HEARTBEAT_COUNTS_KEY: result.remaining})
  deactivate HeartbeatMutex

  Note over HeartbeatMutex: Mutex ensures bumps and alarm updates do not race
Loading

Sequence diagram for router auth_error handling in acceptBatch and retryNow

sequenceDiagram
  actor Popup as PopupOrContent
  participant Router as Router
  participant Queue as SendQueue
  participant Transport as Transport
  participant Retry as RetryScheduler
  participant StorageSession as StorageSession

  Popup->>Router: acceptBatch(messages)
  Router->>Transport: sendBatch(cfg, messages)
  Transport-->>Router: outcome=auth_error
  Router->>Queue: enqueue(messages)
  Router->>Retry: clearRetry()
  Router-->>Popup: outcome=rejected, reason=auth_error

  Note over Router,Queue: Batch is preserved in queue for replay

  Popup->>Router: retryNow()
  Router->>Queue: drainHead(10)
  Queue-->>Router: headBatches
  loop for each batch in headBatches
    Router->>Transport: sendBatch(cfg, batch)
    Transport-->>Router: outcome
    alt outcome==auth_error
      break auth_error
        Router->>Queue: returnHead(failedBatchesAndUntriedTail)
        Router->>Retry: clearRetry()
        Router->>StorageSession: resetAttempt()
        Router-->>Popup: outcome=auth_error
      end
    else outcome==error
      Router->>Queue: trackFailed(batch)
    end
  end
  alt noAuthErrorAndSomeFailed
    Router->>Queue: returnHead(failedBatches)
    Router->>Retry: scheduleRetry(backoff)
    Router-->>Popup: outcome=partial
  else allOk
    Router->>StorageSession: resetAttempt()
    Router->>Retry: clearOrReschedule()
    Router-->>Popup: outcome=ok
  end
Loading

Class diagram for mutex-based serialization across storage components

classDiagram
  class Mutex {
    +run(fn)
  }

  class Queue {
    +enqueue(item)
    +drainHead(n)
    +returnHead(items)
    +clear()
    +size()
    +droppedCount()
    +resetDroppedCount()
    -store
    -mutex
    -maxSize
    -maxBytes
  }

  class Dedup {
    +isFresh(id)
    +size()
    +clear()
    -store
    -mutex
    -key
    -windowSize
  }

  class ConfigStore {
    +loadConfig()
    +saveConfig(patch)
    -saveMutex
  }

  class Router {
    +createRouter()
    +acceptBatch(messages)
    +retryNow()
    +snapshot()
    -queue
    -attemptMutex
  }

  class HeartbeatManager {
    +bumpHeartbeatCount(chatId)
    +onHeartbeatAlarm()
    -heartbeatMutex
  }

  Mutex <.. Queue : uses
  Mutex <.. Dedup : uses
  Mutex <.. ConfigStore : uses
  Mutex <.. Router : attemptMutex
  Mutex <.. HeartbeatManager : heartbeatMutex

  class StorageSession {
    +get(keys)
    +set(items)
  }

  class StorageLocal {
    +get(keys)
    +set(items)
  }

  Queue --> StorageSession : uses
  Router --> StorageSession : attemptCounter
  HeartbeatManager --> StorageLocal : heartbeatCounts
  Dedup --> StorageLocal : default
  ConfigStore --> StorageLocal : config
Loading

File-Level Changes

Change Details Files
Extracts a generic async mutex helper and reimplements queue and dedup locking on top of it, then strengthens their concurrency test coverage.
  • Adds src/lib/mutex.js implementing a per-resource promise-chain mutex with a run(fn) API that preserves ordering and handles errors without breaking the chain.
  • Refactors queue.js to use createMutex for all mutating operations (enqueue, drainHead, returnHead, clear, resetDroppedCount) and extends enqueue with an optional maxBytes eviction path while keeping size/peek/droppedCount as simple reads.
  • Refactors dedup.js to use createMutex around isFresh and clear operations so concurrent calls cannot double-accept the same id, while leaving size as a read-only operation.
  • Extends queue and dedup unit tests with concurrency-focused cases (parallel enqueues/drains for queue, concurrent isFresh calls for dedup) and a new queue maxBytes test to validate byte capping and droppedCount behavior.
  • Adds dedicated mutex unit tests (ordering, error recovery, return-value propagation) to validate the helper in isolation.
extension/src/lib/mutex.js
extension/src/lib/queue.js
extension/src/lib/dedup.js
extension/tests/lib/mutex.test.js
extension/tests/lib/queue.test.js
extension/tests/lib/dedup.test.js
Hardens router behavior around auth_error and retry attempts, including attempt-counter serialization, queue byte-cap wiring, and consistent batch preservation semantics between acceptBatch and retryNow, with new tests.
  • Introduces a QUEUE_MAX_BYTES constant (8 MB) and passes it into createQueue so the router’s session-backed queue respects both count and byte caps.
  • Adds a per-router attemptMutex and rewrites incrementAttempt and resetAttempt as closure helpers that run their read–modify–write sequences under the mutex while keeping getAttempt as a simple read.
  • Changes retryNow’s drain loop to track index, and on auth_error returns both earlier failed batches and not-yet-tried batches to the queue via queue.returnHead, aligning with the documented contract that only the auth-rejected batch is dropped.
  • Updates acceptBatch’s auth_error path to enqueue the batch before clearing the retry alarm so that batches rejected due to bad credentials are preserved for replay after the user fixes the key, keeping behavior consistent with retryNow.
  • Expands router tests to cover auth_error queue preservation for both acceptBatch and retryNow (including mixed server_error/auth_error flows and concurrent retryNow calls stressing the attempt counter).
extension/src/lib/router.js
extension/tests/lib/router.test.js
Serializes config saves and heartbeat count updates under dedicated mutexes to eliminate chrome.storage read–modify–write races, and adds integration tests to validate heartbeat race behavior.
  • Imports createMutex into config.js and wraps saveConfig in a module-level saveMutex so concurrent patches cannot clobber each other when writing the config record.
  • Extends config tests with scenarios where parallel saveConfig calls touch overlapping and disjoint fields to ensure patches compose instead of being lost.
  • Imports createMutex into background.js, adds a heartbeatMutex, and wraps both bumpHeartbeatCount and the heartbeat alarm handler’s runHeartbeat read–write chain under the same mutex to prevent lost increments during heartbeat sends.
  • Adjusts bumpHeartbeatCount to return a promise (or a resolved promise when chatId is falsy) so callers can await the serialized update path.
  • Adds an integration test file that simulates concurrent heartbeat counter bumps using both a mutex-protected and an unprotected implementation to demonstrate the race and validate the fix under the chrome.storage mock.
extension/src/lib/config.js
extension/tests/lib/config.test.js
extension/background.js
extension/tests/integration/heartbeat-race.test.js
Tightens extension permissions and manifest invariants by dropping unused/overscoped permissions and asserting them in tests.
  • Removes the activeTab permission from manifest.json since content scripts are auto-injected via manifest matches and no executeScript-based flows exist.
  • Removes the https:/// host_permissions wildcard, narrowing host access to the specific WhatsApp Web and localhost origins.
  • Updates manifest tests to assert that permissions are limited to alarms and storage, that host_permissions does not contain protocol or catch-all wildcards, and that activeTab is not requested.
extension/manifest.json
extension/tests/manifest.test.js
Improves diagnostic and documentation UX around queue behavior and error handling, including popup diagnostic export robustness and updated storage documentation.
  • Wraps the diagnostic export button handler in popup.js in a try/catch, surfacing failures via setStatus instead of silently dropping errors when collectDiagnostics or Blob/URL operations fail.
  • Updates the README storage section to describe the combined QUEUE_MAX and QUEUE_MAX_BYTES constraints, clarifying how storage.session quota is respected and how droppedCount reflects byte-based evictions, particularly for large audio messages.
  • Adds detailed implementation-plan documents for the connected race sweep and the PR feat(extension): durable queue, modular ESM, and CI hardening #10 bug-fix sweep, capturing rationale, task breakdowns, and verification steps for future reviewers and maintainers.
extension/popup.js
extension/README.md
docs/plans/2026-04-30-connected-races-sweep.md
docs/plans/2026-04-30-pr10-bugfix-sweep.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hey - I've found 1 issue, and left some high level feedback:

  • In the mutex helper, run currently always calls fn as tail.then(fn, fn) but its type is () => Promise<T>; consider either updating the signature to accept an argument or using tail.then(() => fn(), () => fn()) to avoid passing through the previous result and keep the implementation aligned with the declared type.
  • The queue enqueue byte-cap logic repeatedly calls JSON.stringify(arr) inside a loop, which is O(n²) in the worst case for large queues; it may be worth tracking an approximate byte size incrementally or computing the length once per enqueue to avoid redundant full re-serializations.
  • The new parallel saveConfig test uses globalThis.__lastWhitelist without ever assigning to it, which makes the intent a bit confusing; either wire this up properly or simplify the test to more directly exercise the serialized patch behavior.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In the mutex helper, `run` currently always calls `fn` as `tail.then(fn, fn)` but its type is `() => Promise<T>`; consider either updating the signature to accept an argument or using `tail.then(() => fn(), () => fn())` to avoid passing through the previous result and keep the implementation aligned with the declared type.
- The queue `enqueue` byte-cap logic repeatedly calls `JSON.stringify(arr)` inside a loop, which is O(n²) in the worst case for large queues; it may be worth tracking an approximate byte size incrementally or computing the length once per enqueue to avoid redundant full re-serializations.
- The new `parallel saveConfig` test uses `globalThis.__lastWhitelist` without ever assigning to it, which makes the intent a bit confusing; either wire this up properly or simplify the test to more directly exercise the serialized patch behavior.

## Individual Comments

### Comment 1
<location path="extension/tests/lib/config.test.js" line_range="62-71" />
<code_context>
+  it('parallel saveConfig calls do not lose patches', async () => {
</code_context>
<issue_to_address>
**issue (testing):** Race test for `saveConfig` is misleading and relies on an undefined `__lastWhitelist` helper

As written, this test doesn’t model a real read–modify–write flow:

- `globalThis.__lastWhitelist` is never assigned in the test, so every `saveConfig` call sees `undefined` and writes `[name]`; you never exercise “append to existing whitelist”, only concurrent overwrites.
- The final assertion `cfg.whitelist.length >= 1` is too weak and would still pass if all but one write were lost.

I’d either remove this test to avoid a false sense of safety, or rewrite it so each concurrent call explicitly `loadConfig()`, appends a unique marker, then calls `saveConfig`, and you finally assert that all markers are present (order-agnostic, no duplicates). In either case, dropping the unused `globalThis.__lastWhitelist` indirection would make the test clearer.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +62 to +71
it('parallel saveConfig calls do not lose patches', async () => {
await saveConfig({ serverUrl: 'http://x', apiKey: 'k', whitelist: [] });
// Fire 5 concurrent whitelist additions.
const additions = ['Alice', 'Bob', 'Carol', 'Dave', 'Eve'];
await Promise.all(additions.map(name =>
saveConfig({ whitelist: [...((/** @type {any} */(globalThis)).__lastWhitelist ?? []), name] })
));
// The above is racy *by design* — each call reads the current whitelist
// then writes its own append. With a save mutex, a strictly-monotonic
// append-style is safe only if the caller serializes; without the mutex,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

issue (testing): Race test for saveConfig is misleading and relies on an undefined __lastWhitelist helper

As written, this test doesn’t model a real read–modify–write flow:

  • globalThis.__lastWhitelist is never assigned in the test, so every saveConfig call sees undefined and writes [name]; you never exercise “append to existing whitelist”, only concurrent overwrites.
  • The final assertion cfg.whitelist.length >= 1 is too weak and would still pass if all but one write were lost.

I’d either remove this test to avoid a false sense of safety, or rewrite it so each concurrent call explicitly loadConfig(), appends a unique marker, then calls saveConfig, and you finally assert that all markers are present (order-agnostic, no duplicates). In either case, dropping the unused globalThis.__lastWhitelist indirection would make the test clearer.

@DYAI2025
DYAI2025 merged commit f6dd9dc into main Apr 30, 2026
7 checks passed

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c7001afc3a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread extension/manifest.json
Comment on lines 12 to +14
"https://web.whatsapp.com/*",
"http://localhost/*",
"http://127.0.0.1/*",
"https://*/*"
"http://127.0.0.1/*"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Re-add host access for configured HTTPS API endpoints

By removing https://*/*, the extension now only has host access to WhatsApp and localhost, but the UI and config model still allow arbitrary backend URLs (for example https://radar.example.com). In that common deployment case, background fetch calls in the ingest/heartbeat paths will lack required host permission and fail, so captures are never delivered and the retry queue will just grow. This should be replaced with an optional_host_permissions request flow or another mechanism that grants host access to the user-configured server.

Useful? React with 👍 / 👎.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR hardens the extension’s durability and correctness by eliminating several chrome.storage read-modify-write races via a shared mutex helper, tightening manifest permissions, and aligning auth_error handling so queued messages are preserved for replay.

Changes:

  • Introduce createMutex() and apply it to queue/dedup/config/attempt counter/heartbeat counters to prevent lost updates under concurrency.
  • Add an 8 MB serialized byte cap for the send queue (in addition to the existing batch-count cap) to reduce chrome.storage.session quota risk.
  • Tighten manifest permissions/host permissions and expand tests (mutex unit tests, multiple concurrency regression tests, manifest invariants).

Reviewed changes

Copilot reviewed 18 out of 18 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
extension/tests/manifest.test.js Updates permission expectations and adds host wildcard invariants.
extension/tests/lib/router.test.js Adds/updates tests for auth_error preservation and attempt-counter concurrency.
extension/tests/lib/queue.test.js Adds concurrency tests and a max-bytes eviction test for the queue.
extension/tests/lib/mutex.test.js New unit tests for mutex ordering, error recovery, and return values.
extension/tests/lib/dedup.test.js Adds concurrency test to ensure isFresh() returns true only once.
extension/tests/lib/config.test.js Adds concurrency-focused tests for saveConfig serialization behavior.
extension/tests/integration/heartbeat-race.test.js New regression tests demonstrating heartbeat counter races with/without mutex.
extension/src/lib/router.js Adds queue byte cap wiring, serializes attempt counter updates, and changes auth_error handling.
extension/src/lib/queue.js Adds per-instance mutex and optional maxBytes eviction.
extension/src/lib/mutex.js New shared mutex helper used across modules to serialize async sections.
extension/src/lib/dedup.js Wraps isFresh()/clear() in a mutex to prevent concurrent duplicates.
extension/src/lib/config.js Serializes saveConfig() with a module-level mutex to prevent patch clobbering.
extension/popup.js Wraps diagnostic export flow in try/catch and surfaces failures to the user.
extension/manifest.json Removes activeTab and the https://*/* host wildcard.
extension/background.js Serializes heartbeat counter updates and the alarm flush chain with a mutex.
extension/README.md Updates storage quota guidance to reflect the new dual (count + bytes) queue caps.
docs/plans/2026-04-30-pr10-bugfix-sweep.md Adds implementation plan documentation for the original PR10 sweep.
docs/plans/2026-04-30-connected-races-sweep.md Adds implementation plan documentation for the connected races sweep.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 89 to 97
if (r.outcome === 'auth_error') {
// The current batch is rejected (auth is non-retriable, matching
// acceptBatch's contract). Put back any earlier-failed and any
// not-yet-tried batches so they retry once the user fixes the key.
const toReturn = [...failed, ...head.slice(i + 1)];
if (toReturn.length > 0) await queue.returnHead(toReturn);
await clearRetry();
await resetAttempt();
return { outcome: 'auth_error' };
Comment on lines +38 to +42
if (maxBytes) {
while (arr.length > 1 && JSON.stringify(arr).length > maxBytes) {
arr.shift();
dropped++;
}
Comment on lines +62 to +81
it('parallel saveConfig calls do not lose patches', async () => {
await saveConfig({ serverUrl: 'http://x', apiKey: 'k', whitelist: [] });
// Fire 5 concurrent whitelist additions.
const additions = ['Alice', 'Bob', 'Carol', 'Dave', 'Eve'];
await Promise.all(additions.map(name =>
saveConfig({ whitelist: [...((/** @type {any} */(globalThis)).__lastWhitelist ?? []), name] })
));
// The above is racy *by design* — each call reads the current whitelist
// then writes its own append. With a save mutex, a strictly-monotonic
// append-style is safe only if the caller serializes; without the mutex,
// arbitrary patches can lose data. The fairer test:
const cfg = await loadConfig();
// Without serialization, cfg.whitelist could be missing entries because
// each saveConfig overwrote with its own snapshot. With the lock, the
// last writer always sees the most recent state.
// We assert at least one name landed (a weak invariant — strong invariants
// require app-level read-modify-write, which is the user's responsibility).
expect(cfg.whitelist.length).toBeGreaterThanOrEqual(1);
});

Comment thread extension/manifest.json
"http://127.0.0.1/*",
"https://*/*"
"http://127.0.0.1/*"
],
Comment thread extension/README.md
| `session` | `whatsorga_retry_attempt` | router | exponential backoff index | session |

`chrome.storage.session` is capped at 10 MB. With `QUEUE_MAX = 200` batches × ~50 messages/batch × ~500 bytes/message ≈ 5 MB, leaving headroom. Going above this requires raising the storage quota with `"unlimitedStorage"` permission.
`chrome.storage.session` is capped at 10 MB. The router enforces both `QUEUE_MAX = 200` batches and `QUEUE_MAX_BYTES = 8 MB` of serialized payload — whichever evicts first. Audio messages (~100 KB each) hit the byte cap before the count cap, so dropped messages always reflect a real storage-pressure event surfaced via `droppedCount`.
Comment on lines +6 to +9
// Re-create the two race-prone helpers from background.js as imports would
// require pulling in the whole service-worker module. The fix lives in
// background.js so we test the post-fix surface by re-importing after the
// module exports them.
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.

3 participants