mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
c750427ab8
A direct local `plannotator annotate --gate --json` waits for one authoritative decision. If every review surface disappears without approving, sending feedback, or exiting, the caller blocks forever: the server has no notion of whether a client ever connected, whether another tab is still open, or whether a disconnect is a reload. Page lifecycle events cannot answer that. `pagehide` and `beforeunload` also fire on reload and navigation, so dismissing from them ends reviews the user expects to resume. Use connection presence instead, which is exactly what the transport can observe. Local direct structured gates advertise a client lease in /api/plan and serve /api/annotate/client-lease as SSE. One open stream is one connected review surface. The server heartbeats every 5s and, only after at least one client has connected, starts a 30s reconnect grace when the last one disconnects. A reconnect inside the grace continues the same review; expiry resolves the gate through the same path as explicit Close, so it produces an ordinary `dismissed` decision and inherits the strict-result contract unchanged. Approve, feedback, explicit exit, and server stop all cancel a pending expiry. Presence lives in two runtime-independent pieces so Bun and Pi cannot drift. createAnnotateClientLeaseTracker owns first-client, active-count, reconnect, cancellation, and one-shot expiry. createAnnotateClientLease- StreamSession owns one connected client: acquire the slot, write the ready comment, heartbeat, release exactly once. Each server passes only its own write primitive (a ReadableStream controller for Bun, res.write for Pi). A write that fails closes the session, because a stream that can no longer be written to is a client that is no longer present; holding the slot there would make the gate un-dismissable for the rest of the run, which is reachable only through a half-open connection and so is covered by unit tests rather than an integration test. Scope is deliberately narrow. The capability stays off for remote and shared sessions, where tunnel disconnects would read as abandonment, and off for hook transport, legacy plaintext, archive, plan, review, and folder-picker sessions. A session that never receives its first client never auto-dismisses, so browser-launch failures still need a caller-side timeout. Decision settlement is explicit for the same reason: a connected surface and the lease can both try to settle the session, and the awaited promise ignoring the second resolve was not enough. The loser still deleted the reviewer's draft and answered ok, so a tab reported success for a decision the caller never received. createAnnotateDecisionSettler makes the winner explicit; a loser changes nothing and answers 409. Expiry deliberately keeps the saved draft, unlike explicit Close, so an abandoned review stays recoverable. Stopping the server closes live lease streams instead of only releasing their slots, so a long-lived host process does not retain a heartbeat timer and an open response for every finished session.
392 lines
13 KiB
TypeScript
392 lines
13 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
|
|
import {
|
|
ANNOTATE_CLIENT_LEASE_GRACE_MS,
|
|
ANNOTATE_CLIENT_LEASE_HEARTBEAT_COMMENT,
|
|
ANNOTATE_CLIENT_LEASE_HEARTBEAT_MS,
|
|
ANNOTATE_CLIENT_LEASE_READY_COMMENT,
|
|
createAnnotateClientLeaseStreamSession,
|
|
createAnnotateClientLeaseTracker,
|
|
} from "./annotate-client-lease";
|
|
|
|
/**
|
|
* Deterministic virtual clock — tests drive expiry by advancing simulated
|
|
* time instead of sleeping on the real 30s grace period. Timers fire in
|
|
* scheduled order when `advance()` crosses their deadline.
|
|
*/
|
|
function createFakeScheduler() {
|
|
let nextId = 1;
|
|
let now = 0;
|
|
const timers = new Map<number, { at: number; callback: () => void }>();
|
|
|
|
return {
|
|
setTimer: (callback: () => void, ms: number): number => {
|
|
const id = nextId++;
|
|
timers.set(id, { at: now + ms, callback });
|
|
return id;
|
|
},
|
|
clearTimer: (id: number): void => {
|
|
timers.delete(id);
|
|
},
|
|
advance(ms: number): void {
|
|
now += ms;
|
|
const due = [...timers.entries()]
|
|
.filter(([, timer]) => timer.at <= now)
|
|
.sort((a, b) => a[1].at - b[1].at);
|
|
for (const [id, timer] of due) {
|
|
timers.delete(id);
|
|
timer.callback();
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("annotate client-lease tracker: defaults", () => {
|
|
test("exposes the documented heartbeat and grace constants", () => {
|
|
expect(ANNOTATE_CLIENT_LEASE_HEARTBEAT_MS).toBe(5_000);
|
|
expect(ANNOTATE_CLIENT_LEASE_GRACE_MS).toBe(30_000);
|
|
});
|
|
});
|
|
|
|
describe("annotate client-lease tracker: never-connected", () => {
|
|
test("never expires when no client ever connects", () => {
|
|
const scheduler = createFakeScheduler();
|
|
let expireCalls = 0;
|
|
const tracker = createAnnotateClientLeaseTracker(() => {
|
|
expireCalls += 1;
|
|
}, { setTimer: scheduler.setTimer, clearTimer: scheduler.clearTimer });
|
|
|
|
scheduler.advance(ANNOTATE_CLIENT_LEASE_GRACE_MS * 10);
|
|
|
|
expect(expireCalls).toBe(0);
|
|
expect(tracker.activeCount()).toBe(0);
|
|
expect(tracker.isExpired()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("annotate client-lease tracker: last disconnect expiry", () => {
|
|
test("expires exactly at the grace deadline after the only client disconnects", () => {
|
|
const scheduler = createFakeScheduler();
|
|
let expireCalls = 0;
|
|
const tracker = createAnnotateClientLeaseTracker(() => {
|
|
expireCalls += 1;
|
|
}, { setTimer: scheduler.setTimer, clearTimer: scheduler.clearTimer });
|
|
|
|
const release = tracker.acquire();
|
|
expect(tracker.activeCount()).toBe(1);
|
|
|
|
release();
|
|
expect(tracker.activeCount()).toBe(0);
|
|
|
|
scheduler.advance(ANNOTATE_CLIENT_LEASE_GRACE_MS - 1);
|
|
expect(expireCalls).toBe(0);
|
|
expect(tracker.isExpired()).toBe(false);
|
|
|
|
scheduler.advance(1);
|
|
expect(expireCalls).toBe(1);
|
|
expect(tracker.isExpired()).toBe(true);
|
|
});
|
|
|
|
test("honors a custom graceMs override", () => {
|
|
const scheduler = createFakeScheduler();
|
|
let expireCalls = 0;
|
|
const tracker = createAnnotateClientLeaseTracker(() => {
|
|
expireCalls += 1;
|
|
}, { setTimer: scheduler.setTimer, clearTimer: scheduler.clearTimer, graceMs: 1_000 });
|
|
|
|
tracker.acquire()();
|
|
scheduler.advance(999);
|
|
expect(expireCalls).toBe(0);
|
|
scheduler.advance(1);
|
|
expect(expireCalls).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("annotate client-lease tracker: reconnect cancel", () => {
|
|
test("a reconnect before the grace deadline cancels the pending expiry", () => {
|
|
const scheduler = createFakeScheduler();
|
|
let expireCalls = 0;
|
|
const tracker = createAnnotateClientLeaseTracker(() => {
|
|
expireCalls += 1;
|
|
}, { setTimer: scheduler.setTimer, clearTimer: scheduler.clearTimer });
|
|
|
|
tracker.acquire()();
|
|
scheduler.advance(ANNOTATE_CLIENT_LEASE_GRACE_MS / 2);
|
|
expect(expireCalls).toBe(0);
|
|
|
|
// Reconnect cancels the pending timer — advancing past the original
|
|
// deadline must not fire it.
|
|
const release = tracker.acquire();
|
|
scheduler.advance(ANNOTATE_CLIENT_LEASE_GRACE_MS);
|
|
expect(expireCalls).toBe(0);
|
|
expect(tracker.activeCount()).toBe(1);
|
|
|
|
// A fresh disconnect starts its own full grace window.
|
|
release();
|
|
scheduler.advance(ANNOTATE_CLIENT_LEASE_GRACE_MS - 1);
|
|
expect(expireCalls).toBe(0);
|
|
scheduler.advance(1);
|
|
expect(expireCalls).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("annotate client-lease tracker: multiple clients", () => {
|
|
test("only expires once every active client has released", () => {
|
|
const scheduler = createFakeScheduler();
|
|
let expireCalls = 0;
|
|
const tracker = createAnnotateClientLeaseTracker(() => {
|
|
expireCalls += 1;
|
|
}, { setTimer: scheduler.setTimer, clearTimer: scheduler.clearTimer });
|
|
|
|
const releaseFirst = tracker.acquire();
|
|
const releaseSecond = tracker.acquire();
|
|
expect(tracker.activeCount()).toBe(2);
|
|
|
|
releaseFirst();
|
|
expect(tracker.activeCount()).toBe(1);
|
|
scheduler.advance(ANNOTATE_CLIENT_LEASE_GRACE_MS * 2);
|
|
expect(expireCalls).toBe(0);
|
|
|
|
releaseSecond();
|
|
expect(tracker.activeCount()).toBe(0);
|
|
scheduler.advance(ANNOTATE_CLIENT_LEASE_GRACE_MS - 1);
|
|
expect(expireCalls).toBe(0);
|
|
scheduler.advance(1);
|
|
expect(expireCalls).toBe(1);
|
|
});
|
|
|
|
test("release is idempotent — calling it twice does not double-decrement", () => {
|
|
const scheduler = createFakeScheduler();
|
|
let expireCalls = 0;
|
|
const tracker = createAnnotateClientLeaseTracker(() => {
|
|
expireCalls += 1;
|
|
}, { setTimer: scheduler.setTimer, clearTimer: scheduler.clearTimer });
|
|
|
|
const releaseFirst = tracker.acquire();
|
|
tracker.acquire();
|
|
expect(tracker.activeCount()).toBe(2);
|
|
|
|
releaseFirst();
|
|
releaseFirst();
|
|
releaseFirst();
|
|
expect(tracker.activeCount()).toBe(1);
|
|
scheduler.advance(ANNOTATE_CLIENT_LEASE_GRACE_MS);
|
|
expect(expireCalls).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("annotate client-lease tracker: cancel", () => {
|
|
test("cancel() permanently stops tracking, even mid-grace", () => {
|
|
const scheduler = createFakeScheduler();
|
|
let expireCalls = 0;
|
|
const tracker = createAnnotateClientLeaseTracker(() => {
|
|
expireCalls += 1;
|
|
}, { setTimer: scheduler.setTimer, clearTimer: scheduler.clearTimer });
|
|
|
|
tracker.acquire()();
|
|
tracker.cancel();
|
|
scheduler.advance(ANNOTATE_CLIENT_LEASE_GRACE_MS * 5);
|
|
expect(expireCalls).toBe(0);
|
|
|
|
// Acquiring after cancel is a safe no-op — it must not resurrect tracking.
|
|
const release = tracker.acquire();
|
|
expect(tracker.activeCount()).toBe(0);
|
|
release();
|
|
scheduler.advance(ANNOTATE_CLIENT_LEASE_GRACE_MS * 5);
|
|
expect(expireCalls).toBe(0);
|
|
});
|
|
|
|
test("activeCount() stays truthful when a client disconnects after cancel()", () => {
|
|
const scheduler = createFakeScheduler();
|
|
let expireCalls = 0;
|
|
const tracker = createAnnotateClientLeaseTracker(() => {
|
|
expireCalls += 1;
|
|
}, { setTimer: scheduler.setTimer, clearTimer: scheduler.clearTimer });
|
|
|
|
const release = tracker.acquire();
|
|
expect(tracker.activeCount()).toBe(1);
|
|
|
|
tracker.cancel();
|
|
release();
|
|
|
|
expect(tracker.activeCount()).toBe(0);
|
|
scheduler.advance(ANNOTATE_CLIENT_LEASE_GRACE_MS * 5);
|
|
expect(expireCalls).toBe(0);
|
|
});
|
|
|
|
test("cancel() is idempotent and safe before any client ever connects", () => {
|
|
const scheduler = createFakeScheduler();
|
|
let expireCalls = 0;
|
|
const tracker = createAnnotateClientLeaseTracker(() => {
|
|
expireCalls += 1;
|
|
}, { setTimer: scheduler.setTimer, clearTimer: scheduler.clearTimer });
|
|
|
|
tracker.cancel();
|
|
tracker.cancel();
|
|
scheduler.advance(ANNOTATE_CLIENT_LEASE_GRACE_MS * 5);
|
|
expect(expireCalls).toBe(0);
|
|
expect(tracker.isExpired()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("annotate client-lease tracker: once", () => {
|
|
test("the expiry callback fires at most once", () => {
|
|
const scheduler = createFakeScheduler();
|
|
let expireCalls = 0;
|
|
const tracker = createAnnotateClientLeaseTracker(() => {
|
|
expireCalls += 1;
|
|
}, { setTimer: scheduler.setTimer, clearTimer: scheduler.clearTimer });
|
|
|
|
tracker.acquire()();
|
|
scheduler.advance(ANNOTATE_CLIENT_LEASE_GRACE_MS);
|
|
expect(expireCalls).toBe(1);
|
|
|
|
// Further time passing, or redundant releases, must not refire it.
|
|
scheduler.advance(ANNOTATE_CLIENT_LEASE_GRACE_MS * 10);
|
|
expect(expireCalls).toBe(1);
|
|
expect(tracker.isExpired()).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("annotate client-lease stream session", () => {
|
|
function setup(options: { failReady?: boolean; failHeartbeatAfter?: number } = {}) {
|
|
const scheduler = createFakeScheduler();
|
|
let expireCalls = 0;
|
|
const tracker = createAnnotateClientLeaseTracker(() => {
|
|
expireCalls += 1;
|
|
}, { setTimer: scheduler.setTimer, clearTimer: scheduler.clearTimer });
|
|
|
|
const written: string[] = [];
|
|
let heartbeats = 0;
|
|
const session = createAnnotateClientLeaseStreamSession({
|
|
tracker,
|
|
heartbeatMs: ANNOTATE_CLIENT_LEASE_HEARTBEAT_MS,
|
|
// The fake scheduler is one-shot, so re-arm to emulate setInterval.
|
|
setHeartbeat: (callback, ms) => {
|
|
const repeat = () => {
|
|
scheduler.setTimer(repeat, ms);
|
|
callback();
|
|
};
|
|
return scheduler.setTimer(repeat, ms);
|
|
},
|
|
clearHeartbeat: (handle) => scheduler.clearTimer(handle as number),
|
|
write: (chunk) => {
|
|
if (chunk === ANNOTATE_CLIENT_LEASE_READY_COMMENT && options.failReady) {
|
|
throw new Error("peer gone");
|
|
}
|
|
if (chunk === ANNOTATE_CLIENT_LEASE_HEARTBEAT_COMMENT) {
|
|
heartbeats += 1;
|
|
if (options.failHeartbeatAfter !== undefined && heartbeats > options.failHeartbeatAfter) {
|
|
throw new Error("peer gone");
|
|
}
|
|
}
|
|
written.push(chunk);
|
|
},
|
|
});
|
|
|
|
return { scheduler, tracker, session, written, expireCalls: () => expireCalls };
|
|
}
|
|
|
|
test("acquires the slot and writes the ready comment on connect", () => {
|
|
const { tracker, written, session } = setup();
|
|
|
|
expect(written).toEqual([ANNOTATE_CLIENT_LEASE_READY_COMMENT]);
|
|
expect(tracker.activeCount()).toBe(1);
|
|
expect(session.isClosed()).toBe(false);
|
|
});
|
|
|
|
test("heartbeats on the configured interval while connected", () => {
|
|
const { scheduler, written, tracker, expireCalls } = setup();
|
|
|
|
// One advance per interval: the fake scheduler fires only timers that were
|
|
// already due when it was called, so a re-armed heartbeat needs its own tick.
|
|
scheduler.advance(ANNOTATE_CLIENT_LEASE_HEARTBEAT_MS);
|
|
scheduler.advance(ANNOTATE_CLIENT_LEASE_HEARTBEAT_MS);
|
|
scheduler.advance(ANNOTATE_CLIENT_LEASE_HEARTBEAT_MS);
|
|
|
|
expect(written.filter((c) => c === ANNOTATE_CLIENT_LEASE_HEARTBEAT_COMMENT)).toHaveLength(3);
|
|
expect(tracker.activeCount()).toBe(1);
|
|
expect(expireCalls()).toBe(0);
|
|
});
|
|
|
|
test("close() releases the slot once and starts the grace period", () => {
|
|
const { scheduler, session, tracker, expireCalls } = setup();
|
|
|
|
session.close();
|
|
session.close();
|
|
|
|
expect(session.isClosed()).toBe(true);
|
|
expect(tracker.activeCount()).toBe(0);
|
|
scheduler.advance(ANNOTATE_CLIENT_LEASE_GRACE_MS);
|
|
expect(expireCalls()).toBe(1);
|
|
});
|
|
|
|
test("a failed ready write closes the session so the gate stays dismissable", () => {
|
|
const { scheduler, session, tracker, expireCalls } = setup({ failReady: true });
|
|
|
|
expect(session.isClosed()).toBe(true);
|
|
expect(tracker.activeCount()).toBe(0);
|
|
scheduler.advance(ANNOTATE_CLIENT_LEASE_GRACE_MS);
|
|
expect(expireCalls()).toBe(1);
|
|
});
|
|
|
|
test("a failed heartbeat write releases the slot instead of holding it forever", () => {
|
|
const { scheduler, session, tracker, expireCalls } = setup({ failHeartbeatAfter: 1 });
|
|
|
|
scheduler.advance(ANNOTATE_CLIENT_LEASE_HEARTBEAT_MS);
|
|
expect(tracker.activeCount()).toBe(1);
|
|
|
|
scheduler.advance(ANNOTATE_CLIENT_LEASE_HEARTBEAT_MS);
|
|
expect(session.isClosed()).toBe(true);
|
|
expect(tracker.activeCount()).toBe(0);
|
|
|
|
scheduler.advance(ANNOTATE_CLIENT_LEASE_GRACE_MS);
|
|
expect(expireCalls()).toBe(1);
|
|
});
|
|
|
|
test("closeSessions() ends every live session, so shutdown leaves nothing running", () => {
|
|
const { scheduler, tracker, session, written, expireCalls } = setup();
|
|
|
|
tracker.cancel();
|
|
tracker.closeSessions();
|
|
|
|
expect(session.isClosed()).toBe(true);
|
|
expect(tracker.activeCount()).toBe(0);
|
|
scheduler.advance(ANNOTATE_CLIENT_LEASE_HEARTBEAT_MS * 5);
|
|
expect(written).toEqual([ANNOTATE_CLIENT_LEASE_READY_COMMENT]);
|
|
// Cancelled means abandonment no longer matters: no expiry may fire.
|
|
scheduler.advance(ANNOTATE_CLIENT_LEASE_GRACE_MS);
|
|
expect(expireCalls()).toBe(0);
|
|
});
|
|
|
|
test("close() ends the underlying stream exactly once", () => {
|
|
const scheduler = createFakeScheduler();
|
|
const tracker = createAnnotateClientLeaseTracker(() => {}, {
|
|
setTimer: scheduler.setTimer,
|
|
clearTimer: scheduler.clearTimer,
|
|
});
|
|
let ended = 0;
|
|
const session = createAnnotateClientLeaseStreamSession({
|
|
tracker,
|
|
write: () => {},
|
|
endStream: () => {
|
|
ended += 1;
|
|
},
|
|
});
|
|
|
|
session.close();
|
|
session.close();
|
|
|
|
expect(ended).toBe(1);
|
|
});
|
|
|
|
test("no heartbeat is written after close", () => {
|
|
const { scheduler, session, written } = setup();
|
|
|
|
session.close();
|
|
scheduler.advance(ANNOTATE_CLIENT_LEASE_HEARTBEAT_MS * 5);
|
|
|
|
expect(written).toEqual([ANNOTATE_CLIENT_LEASE_READY_COMMENT]);
|
|
});
|
|
});
|