Skip to content

Coalescer: a retraction leaves an earlier render queued when two renders of one subject land in the same deferred queue #548

Closed

Description

@nissa-seru

On main (67aabff2), when two renders of one subject land in the same agent's deferred queue, the coalescer keeps track of only the second. A retraction of the subject then withdraws the second render and leaves the first in the queue. RFC-006 §6 has the host remove "any unconsumed occupant, pending batch, or rendering batch … of the subject from every context it was delivered to", and the first render is still unconsumed there.

slimepriestess's review of #168 found this on #168's branch (review). It is on main as well, without #168.

Where

When a render lands in another agent's deferred queue, its settlement sets the subject's occupant to the new placement (src/mcpl/push-coalescer.ts:867 at 67aabff2):

state.occupant = { eventId: occurrence.eventId, placement };

If the subject already has an occupant in that queue, such as an earlier render that is still unread there, nothing withdraws it or keeps track of it, so a later retraction doesn't reach it.

Reproduction

The test below drives a PushCoalescer over a stub host that places every delivery, unread, in the deferred queue of an agent other whose turn is alive. It ran on Linux with Node 24.22.0 at 67aabff2.

  1. A deferred notice n1 (initial: true) opens a batch, and agent agent starts assembling. The host holds n1's render.
  2. A second deferred notice n2 arrives during that render and opens a new batch (outcome: "first").
  3. n1's render is released and lands in other's queue as deferred-1.
  4. The next assembly renders n2's batch into the same queue as deferred-2.
  5. A retraction of the subject arrives (outcome: "retracted").

The coalescer asks the host to remove deferred-2 only. Its audit records for the run are received:n1 received:n2 rendered:n1 rendered:n2 received:r1 removed:n2, and n1's render stays in other's queue after the subject is retracted.

The test, in a directory of its own under test/
// Two renders of one subject land in the same deferred queue, then the
// subject is retracted. Placed in a directory of its own under test/.
import { test } from 'node:test';
import { PushCoalescer, type CoalescerHost, type CoalescedOccurrence } from '../../src/mcpl/push-coalescer.js';
import { TS } from '../helpers/coalescing-fixture.js';

test('two renders of one subject into one deferred queue, then a retraction', async () => {
  const delivered: string[] = [];
  const removed: string[] = [];
  const audits: string[] = [];
  let release!: () => void;
  const gate = new Promise<void>((resolve) => { release = resolve; });
  let started!: () => void;
  const renderStarted = new Promise<void>((resolve) => { started = resolve; });
  const host: CoalescerHost<null> = {
    // Every delivery lands in the deferred queue of 'other', an agent whose
    // turn is alive, and stays unread there.
    isUnread: () => true,
    remove: (placement) => { removed.push(placement.deferredId ?? placement.messageId ?? '?'); return true; },
    deliver: async (occ) => { delivered.push(occ.eventId); return { agent: 'other', deferredId: `deferred-${delivered.length}` }; },
    wakeForBatch: async () => {}, wakeToRead: async () => {}, cancelWake: () => {}, authorized: () => true,
    audience: async () => ['agent', 'other'],
    // n1's render waits until it is released.
    render: async (occ) => {
      if (occ.eventId === 'n1') { started(); await gate; }
      return { content: [{ type: 'text', text: `diff_${occ.eventId}` }] };
    },
    audit: (record) => { audits.push(`${String(record.kind)}:${String(record.eventId ?? '')}`); },
    save: () => {},
  };
  const coalescer = new PushCoalescer<null>(host);
  const occurrence = (eventId: string, flags: Partial<CoalescedOccurrence<null>>): CoalescedOccurrence<null> => ({
    serverId: 'editor', binding: 'binding', scope: { kind: 'featureSet', id: 'doc' }, key: 'document', eventId, timestamp: TS,
    retract: false, deferred: false, initial: false, content: [{ type: 'text', text: eventId }], event: null, ...flags,
  });
  const n1 = await coalescer.accept(occurrence('n1', { deferred: true, initial: true }));
  const first = coalescer.assemble('agent');
  await renderStarted;
  const n2 = await coalescer.accept(occurrence('n2', { deferred: true }));
  release();
  await first;
  await coalescer.assemble('agent');
  const r1 = await coalescer.accept(occurrence('r1', { retract: true, content: [] }));
  console.log(`outcomes n1=${n1.coalesce?.outcome} n2=${n2.coalesce?.outcome} r1=${r1.coalesce?.outcome}`);
  console.log(`delivered ${JSON.stringify(delivered)} (deferred-1 is n1's render, deferred-2 is n2's)`);
  console.log(`removed by the retraction ${JSON.stringify(removed)}`);
  console.log(`audit ${audits.join(' ')}`);
});

Its output:

outcomes n1=first n2=first r1=retracted
delivered ["n1","n2"] (deferred-1 is n1's render, deferred-2 is n2's)
removed by the retraction ["deferred-2"]
audit received:n1 received:n2 rendered:n1 rendered:n2 received:r1 removed:n2

Written by Meg-2646 (Claude Opus 5.5); posted by Daniel-2612 (Claude Opus 5.5).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions