Skip to content

Commit 1705966

Browse files
committed
refactor(markdown): replace boolean flags with counters for sync coordination
1 parent 41b6211 commit 1705966

File tree

2 files changed

+106
-93
lines changed

2 files changed

+106
-93
lines changed

apps/tab-manager/src/entrypoints/background.ts

Lines changed: 75 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -59,17 +59,30 @@ import { BROWSER_SCHEMA } from '$lib/epicenter/schema';
5959
* The observers check `origin` to distinguish remote vs local changes and only
6060
* act on remote changes (when a markdown file changes on the server).
6161
*
62-
* Flags for secondary coordination:
63-
* - `isProcessingYDocChange`: Set when calling Browser APIs from Y.Doc observers
62+
* Counters for secondary coordination (not booleans - see below):
63+
* - `yDocChangeCount`: Incremented when calling Browser APIs from Y.Doc observers
6464
* Prevents Browser events from triggering refetch during our own API calls.
65-
* - `isRefetching`: Set when syncing Browser → Y.Doc (refetch functions)
65+
* - `refetchCount`: Incremented when syncing Browser → Y.Doc (refetch functions)
6666
* Used as a secondary guard in refetch helpers.
67+
*
68+
* Why counters instead of booleans:
69+
* Multiple async operations can run concurrently. A boolean causes race conditions:
70+
* - Event A sets flag = true, awaits async work
71+
* - Event B sets flag = true, awaits async work
72+
* - Event A completes, sets flag = false (BUG! B is still working)
73+
* - Observer sees false, processes B's side effect, creates infinite loop
74+
*
75+
* With counters:
76+
* - Event A increments to 1, awaits async work
77+
* - Event B increments to 2, awaits async work
78+
* - Event A completes, decrements to 1 (still > 0, protected)
79+
* - Event B completes, decrements to 0
6780
*/
6881
const syncCoordination = {
69-
/** True when we're processing a Y.Doc change (calling Browser APIs) */
70-
isProcessingYDocChange: false,
71-
/** True when we're refetching Browser state into Y.Doc */
72-
isRefetching: false,
82+
/** Count of concurrent Y.Doc change handlers calling Browser APIs */
83+
yDocChangeCount: 0,
84+
/** Count of concurrent refetch operations (Browser Y.Doc) */
85+
refetchCount: 0,
7386
};
7487

7588
// ─────────────────────────────────────────────────────────────────────────────
@@ -382,59 +395,59 @@ export default defineBackground(() => {
382395
// onCreated: Full Tab object provided
383396
browser.tabs.onCreated.addListener(async (tab) => {
384397
await initPromise;
385-
if (syncCoordination.isProcessingYDocChange) return;
398+
if (syncCoordination.yDocChangeCount > 0) return;
386399
if (tab.id === undefined) return;
387400

388401
const deviceId = await deviceIdPromise;
389-
syncCoordination.isRefetching = true;
402+
syncCoordination.refetchCount++;
390403
tables.tabs.upsert(browserTabToRow({ tab, deviceId }));
391-
syncCoordination.isRefetching = false;
404+
syncCoordination.refetchCount--;
392405
});
393406

394407
// onRemoved: Only tabId provided - delete directly
395408
browser.tabs.onRemoved.addListener(async (tabId) => {
396409
await initPromise;
397-
if (syncCoordination.isProcessingYDocChange) return;
410+
if (syncCoordination.yDocChangeCount > 0) return;
398411

399412
const deviceId = await deviceIdPromise;
400413
const { TabId } = createCompositeIds(deviceId);
401-
syncCoordination.isRefetching = true;
414+
syncCoordination.refetchCount++;
402415
tables.tabs.delete({ id: TabId(tabId) });
403-
syncCoordination.isRefetching = false;
416+
syncCoordination.refetchCount--;
404417
});
405418

406419
// onUpdated: Full Tab object provided (3rd arg)
407420
browser.tabs.onUpdated.addListener(async (_tabId, _changeInfo, tab) => {
408421
await initPromise;
409-
if (syncCoordination.isProcessingYDocChange) return;
422+
if (syncCoordination.yDocChangeCount > 0) return;
410423
if (tab.id === undefined) return;
411424

412425
const deviceId = await deviceIdPromise;
413-
syncCoordination.isRefetching = true;
426+
syncCoordination.refetchCount++;
414427
tables.tabs.upsert(browserTabToRow({ tab, deviceId }));
415-
syncCoordination.isRefetching = false;
428+
syncCoordination.refetchCount--;
416429
});
417430

418431
// onMoved: Only tabId + moveInfo provided - need to query Browser
419432
browser.tabs.onMoved.addListener(async (tabId) => {
420433
await initPromise;
421-
if (syncCoordination.isProcessingYDocChange) return;
434+
if (syncCoordination.yDocChangeCount > 0) return;
422435

423-
syncCoordination.isRefetching = true;
436+
syncCoordination.refetchCount++;
424437
await upsertTabById(tabId);
425-
syncCoordination.isRefetching = false;
438+
syncCoordination.refetchCount--;
426439
});
427440

428441
// onActivated: Only activeInfo provided - need to query Browser
429442
// Note: We need to update BOTH the newly activated tab AND the previously active tab
430443
// in the same window (to set active: false on the old one)
431444
browser.tabs.onActivated.addListener(async (activeInfo) => {
432445
await initPromise;
433-
if (syncCoordination.isProcessingYDocChange) return;
446+
if (syncCoordination.yDocChangeCount > 0) return;
434447

435448
const deviceId = await deviceIdPromise;
436449
const { TabId, WindowId } = createCompositeIds(deviceId);
437-
syncCoordination.isRefetching = true;
450+
syncCoordination.refetchCount++;
438451

439452
const deviceWindowId = WindowId(activeInfo.windowId);
440453
const deviceTabId = TabId(activeInfo.tabId);
@@ -451,27 +464,27 @@ export default defineBackground(() => {
451464
// Update the newly activated tab
452465
await upsertTabById(activeInfo.tabId);
453466

454-
syncCoordination.isRefetching = false;
467+
syncCoordination.refetchCount--;
455468
});
456469

457470
// onAttached: Tab moved between windows - need to query Browser
458471
browser.tabs.onAttached.addListener(async (tabId) => {
459472
await initPromise;
460-
if (syncCoordination.isProcessingYDocChange) return;
473+
if (syncCoordination.yDocChangeCount > 0) return;
461474

462-
syncCoordination.isRefetching = true;
475+
syncCoordination.refetchCount++;
463476
await upsertTabById(tabId);
464-
syncCoordination.isRefetching = false;
477+
syncCoordination.refetchCount--;
465478
});
466479

467480
// onDetached: Tab detached from window - need to query Browser
468481
browser.tabs.onDetached.addListener(async (tabId) => {
469482
await initPromise;
470-
if (syncCoordination.isProcessingYDocChange) return;
483+
if (syncCoordination.yDocChangeCount > 0) return;
471484

472-
syncCoordination.isRefetching = true;
485+
syncCoordination.refetchCount++;
473486
await upsertTabById(tabId);
474-
syncCoordination.isRefetching = false;
487+
syncCoordination.refetchCount--;
475488
});
476489

477490
// ─────────────────────────────────────────────────────────────────────────
@@ -481,37 +494,37 @@ export default defineBackground(() => {
481494
// onCreated: Full Window object provided
482495
browser.windows.onCreated.addListener(async (window) => {
483496
await initPromise;
484-
if (syncCoordination.isProcessingYDocChange) return;
497+
if (syncCoordination.yDocChangeCount > 0) return;
485498
if (window.id === undefined) return;
486499

487500
const deviceId = await deviceIdPromise;
488-
syncCoordination.isRefetching = true;
501+
syncCoordination.refetchCount++;
489502
tables.windows.upsert(browserWindowToRow({ window, deviceId }));
490-
syncCoordination.isRefetching = false;
503+
syncCoordination.refetchCount--;
491504
});
492505

493506
// onRemoved: Only windowId provided - delete directly
494507
browser.windows.onRemoved.addListener(async (windowId) => {
495508
await initPromise;
496-
if (syncCoordination.isProcessingYDocChange) return;
509+
if (syncCoordination.yDocChangeCount > 0) return;
497510

498511
const deviceId = await deviceIdPromise;
499512
const { WindowId } = createCompositeIds(deviceId);
500-
syncCoordination.isRefetching = true;
513+
syncCoordination.refetchCount++;
501514
tables.windows.delete({ id: WindowId(windowId) });
502-
syncCoordination.isRefetching = false;
515+
syncCoordination.refetchCount--;
503516
});
504517

505518
// onFocusChanged: Only windowId provided - need to query Browser
506519
// Note: windowId can be WINDOW_ID_NONE (-1) when all windows lose focus
507520
// We need to update BOTH the newly focused window AND previously focused windows
508521
browser.windows.onFocusChanged.addListener(async (windowId) => {
509522
await initPromise;
510-
if (syncCoordination.isProcessingYDocChange) return;
523+
if (syncCoordination.yDocChangeCount > 0) return;
511524

512525
const deviceId = await deviceIdPromise;
513526
const { WindowId } = createCompositeIds(deviceId);
514-
syncCoordination.isRefetching = true;
527+
syncCoordination.refetchCount++;
515528

516529
const deviceWindowId = WindowId(windowId);
517530

@@ -529,7 +542,7 @@ export default defineBackground(() => {
529542
await upsertWindowById(windowId);
530543
}
531544

532-
syncCoordination.isRefetching = false;
545+
syncCoordination.refetchCount--;
533546
});
534547

535548
// ─────────────────────────────────────────────────────────────────────────
@@ -540,35 +553,35 @@ export default defineBackground(() => {
540553
// onCreated: Full TabGroup object provided
541554
browser.tabGroups.onCreated.addListener(async (group) => {
542555
await initPromise;
543-
if (syncCoordination.isProcessingYDocChange) return;
556+
if (syncCoordination.yDocChangeCount > 0) return;
544557

545558
const deviceId = await deviceIdPromise;
546-
syncCoordination.isRefetching = true;
559+
syncCoordination.refetchCount++;
547560
tables.tab_groups.upsert(browserTabGroupToRow({ group, deviceId }));
548-
syncCoordination.isRefetching = false;
561+
syncCoordination.refetchCount--;
549562
});
550563

551564
// onRemoved: Full TabGroup object provided
552565
browser.tabGroups.onRemoved.addListener(async (group) => {
553566
await initPromise;
554-
if (syncCoordination.isProcessingYDocChange) return;
567+
if (syncCoordination.yDocChangeCount > 0) return;
555568

556569
const deviceId = await deviceIdPromise;
557570
const { GroupId } = createCompositeIds(deviceId);
558-
syncCoordination.isRefetching = true;
571+
syncCoordination.refetchCount++;
559572
tables.tab_groups.delete({ id: GroupId(group.id) });
560-
syncCoordination.isRefetching = false;
573+
syncCoordination.refetchCount--;
561574
});
562575

563576
// onUpdated: Full TabGroup object provided
564577
browser.tabGroups.onUpdated.addListener(async (group) => {
565578
await initPromise;
566-
if (syncCoordination.isProcessingYDocChange) return;
579+
if (syncCoordination.yDocChangeCount > 0) return;
567580

568581
const deviceId = await deviceIdPromise;
569-
syncCoordination.isRefetching = true;
582+
syncCoordination.refetchCount++;
570583
tables.tab_groups.upsert(browserTabGroupToRow({ group, deviceId }));
571-
syncCoordination.isRefetching = false;
584+
syncCoordination.refetchCount--;
572585
});
573586
}
574587

@@ -619,8 +632,8 @@ export default defineBackground(() => {
619632
}
620633

621634
console.log('[Background] tabs.onAdd CREATING tab with URL:', row.url);
622-
// Set flag to prevent Browser events from triggering refetch
623-
syncCoordination.isProcessingYDocChange = true;
635+
// Increment counter to prevent Browser events from triggering refetch
636+
syncCoordination.yDocChangeCount++;
624637
await tryAsync({
625638
try: async () => {
626639
// Create the tab with the URL from the markdown file
@@ -632,9 +645,9 @@ export default defineBackground(() => {
632645
// Refetch to clean up - this will:
633646
// 1. Add the new Browser tab (with Browser's real ID)
634647
// 2. Delete the old row (wrong ID from markdown, not in Browser)
635-
syncCoordination.isRefetching = true;
648+
syncCoordination.refetchCount++;
636649
await client.refetchTabs();
637-
syncCoordination.isRefetching = false;
650+
syncCoordination.refetchCount--;
638651
console.log('[Background] tabs.onAdd refetch complete');
639652
},
640653
catch: (error) => {
@@ -645,7 +658,7 @@ export default defineBackground(() => {
645658
return Ok(undefined);
646659
},
647660
});
648-
syncCoordination.isProcessingYDocChange = false;
661+
syncCoordination.yDocChangeCount--;
649662
},
650663
onDelete: async (id, transaction) => {
651664
await initPromise;
@@ -676,8 +689,8 @@ export default defineBackground(() => {
676689
}
677690

678691
console.log('[Background] tabs.onDelete REMOVING Browser tab:', parsed.tabId);
679-
// Set flag to prevent Browser events from triggering refetch
680-
syncCoordination.isProcessingYDocChange = true;
692+
// Increment counter to prevent Browser events from triggering refetch
693+
syncCoordination.yDocChangeCount++;
681694
await tryAsync({
682695
try: async () => {
683696
await browser.tabs.remove(parsed.tabId);
@@ -692,7 +705,7 @@ export default defineBackground(() => {
692705
return Ok(undefined);
693706
},
694707
});
695-
syncCoordination.isProcessingYDocChange = false;
708+
syncCoordination.yDocChangeCount--;
696709
},
697710
});
698711

@@ -709,16 +722,16 @@ export default defineBackground(() => {
709722
// Only process if this window is meant for THIS device
710723
if (result.data.device_id !== deviceId) return;
711724

712-
syncCoordination.isProcessingYDocChange = true;
725+
syncCoordination.yDocChangeCount++;
713726
await tryAsync({
714727
try: async () => {
715728
// Create a new window
716729
await browser.windows.create({});
717730

718731
// Refetch to clean up
719-
syncCoordination.isRefetching = true;
732+
syncCoordination.refetchCount++;
720733
await client.refetchWindows();
721-
syncCoordination.isRefetching = false;
734+
syncCoordination.refetchCount--;
722735
},
723736
catch: (error) => {
724737
console.log(
@@ -728,7 +741,7 @@ export default defineBackground(() => {
728741
return Ok(undefined);
729742
},
730743
});
731-
syncCoordination.isProcessingYDocChange = false;
744+
syncCoordination.yDocChangeCount--;
732745
},
733746
onDelete: async (id, transaction) => {
734747
await initPromise;
@@ -742,7 +755,7 @@ export default defineBackground(() => {
742755
// Only close windows that belong to THIS device
743756
if (!parsed || parsed.deviceId !== deviceId) return;
744757

745-
syncCoordination.isProcessingYDocChange = true;
758+
syncCoordination.yDocChangeCount++;
746759
await tryAsync({
747760
try: async () => {
748761
await browser.windows.remove(parsed.windowId);
@@ -752,7 +765,7 @@ export default defineBackground(() => {
752765
return Ok(undefined);
753766
},
754767
});
755-
syncCoordination.isProcessingYDocChange = false;
768+
syncCoordination.yDocChangeCount--;
756769
},
757770
});
758771

@@ -770,7 +783,7 @@ export default defineBackground(() => {
770783
// Only ungroup tabs from THIS device's groups
771784
if (!parsed || parsed.deviceId !== deviceId) return;
772785

773-
syncCoordination.isProcessingYDocChange = true;
786+
syncCoordination.yDocChangeCount++;
774787
await tryAsync({
775788
try: async () => {
776789
// Note: Browser doesn't have tabGroups.remove(), but we can ungroup tabs
@@ -789,7 +802,7 @@ export default defineBackground(() => {
789802
return Ok(undefined);
790803
},
791804
});
792-
syncCoordination.isProcessingYDocChange = false;
805+
syncCoordination.yDocChangeCount--;
793806
},
794807
});
795808
}

0 commit comments

Comments
 (0)