plugin: benign-reconnect overflow-notice fix (maxAssignedSeq gate), null-id reconnect timer clear; rsync: mkdir before early exit

This commit is contained in:
Raphael Westphal
2026-08-18 19:04:05 +02:00
parent 6aac763563
commit 95d2b5da4b
2 changed files with 36 additions and 14 deletions
+32 -12
View File
@@ -93,6 +93,7 @@ interface SessionSnapshot {
/** Per-session seq counters survive reconnects and instance rebinds in-process. */
const seqCounters: Map<string, number> = new Map();
const maxAssignedSeq: Map<string, number> = new Map();
const sessionStartTs: Map<string, number> = new Map();
const logPath: string = path.join(os.homedir(), ".pi", "lvmh-agent.log");
@@ -110,6 +111,11 @@ function log(message: unknown): void {
function nextSeq(sessionId: string): number {
const n: number = (seqCounters.get(sessionId) ?? 0) + 1;
seqCounters.set(sessionId, n);
// High-water mark of seqs THIS process produced — distinguishes a benign
// reconnect (daemon lastSeq <= maxAssigned: frames were delivered by us)
// from a foreign high-water after a plugin restart (real gaps).
const seen = maxAssignedSeq.get(sessionId) ?? 0;
if (n > seen) maxAssignedSeq.set(sessionId, n);
return n;
}
@@ -487,18 +493,26 @@ export default function (pi: ExtensionAPI): void {
const counter: number = seqCounters.get(currentSessionId) ?? 0;
if (counter <= lastSeq) seqCounters.set(currentSessionId, lastSeq);
}
// Events already covered by the daemon's lastSeq are silently absent
// from both replay and queue below — count them so the buffer_overflow
// notice reflects every gap, not just cap drops.
const covered: number =
replayBuf.filter((f) => f.seq <= lastSeq).length +
sendQueue.filter((f) => !TRANSIENT_TYPES.has(f.type) && f.seq <= lastSeq)
.length;
if (covered > 0) {
droppedEvents += covered;
log(
`welcome lastSeq=${lastSeq} covers ${covered} in-flight events; flagging`,
);
// Gap accounting: only a daemon lastSeq ABOVE anything this process ever
// assigned indicates real gaps (plugin restart against a daemon that has
// history). lastSeq <= maxAssigned means the daemon acked frames we sent
// ourselves — benign reconnect, no notice. replayBuf alone holds every
// persisted-kind frame (sendQueue copies are a subset), so counting it
// once cannot double-count.
const maxAssigned: number =
currentSessionId !== null
? (maxAssignedSeq.get(currentSessionId) ?? 0)
: 0;
if (lastSeq > maxAssigned) {
const covered: number = replayBuf.filter(
(f) => f.seq <= lastSeq,
).length;
if (covered > 0) {
droppedEvents += covered;
log(
`welcome lastSeq=${lastSeq} > maxAssigned=${maxAssigned}: ${covered} replayed frames never acknowledged; flagging`,
);
}
}
// Replay persisted events the daemon is missing, before queued live
// frames. Persisted-kind frames still sitting in the queue are dropped
@@ -637,6 +651,12 @@ export default function (pi: ExtensionAPI): void {
// attributing this session's events to the previous session id.
currentSessionId = null;
handleDisconnect();
try {
if (reconnectTimer !== null) clearTimeout(reconnectTimer);
} catch {
// clearable timers never throw in practice
}
reconnectTimer = null;
return;
}
const assignedId = process.env[ENV_SESSION_ID];