/** * The IDLE loop: RFC 2177 push, and the five things about it that are easy to * get wrong. * * One long-lived connection per mailbox sends `IDLE`, waits for the server's * `+` continuation, and then sits in silence until the server says something. * Everything interesting is in the details of ending that silence. * * **1. `DONE` is not a tagged command.** It is a bare line answering the * server's continuation request. The tagged completion that follows belongs to * the ORIGINAL `IDLE` tag, allocating a new tag for `DONE`, or expecting a * completion under one, leaves the real completion unmatched and the reader * waiting for a line that already went past. * * **2. Untagged responses arrive during the `DONE` handshake.** The window * between writing `DONE` and reading the tagged completion is a real interval * on a real network, and mail delivered into it produces an `EXISTS` that is * every bit as real as one delivered a second earlier. A loop that subscribes * for the wait and unsubscribes before the handshake drops those, silently, * and only under load, which is when it matters. So the subscription is opened * BEFORE `IDLE` goes out and closed AFTER the tagged completion comes back, * and everything collected in between counts. * * **3. `EXISTS` is a mailbox TOTAL, not a delta.** `* 12 EXISTS` says the * mailbox now holds twelve messages. It does not say message 12 is new, it * does not say one message arrived, and subtracting the previous total is * arithmetic on a number an expunge can move downwards. It is a WAKE-UP and * nothing else; the delta always comes from `UID SEARCH UID :*`. * Nothing in this file ever turns an `EXISTS` number into a message identity. * * **4. `EXPUNGE` renumbers everything above it.** It cannot produce new mail, * so it needs no refetch, but it invalidates every sequence number in flight. * This loop never holds sequence-number work to invalidate, because the delta * is UID-keyed end to end; an `EXPUNGE` is therefore recorded and otherwise * ignored, which is the correct amount of work to do about it. * * **5. A dead TCP connection reads as a healthy IDLE forever.** No bytes are * expected during an IDLE, so "no bytes arrived" is indistinguishable from * "the socket died two hours ago", and that is the exact failure this whole * capability exists to eliminate. The re-issue timer therefore doubles as the * liveness probe: every `idleReissueMs` the loop ends the IDLE and starts a * new one, and if that round trip does not complete within the operation * timeout the connection is declared dead and rebuilt. The interval is set * well inside RFC 2177's 29-minute advisory, which exists because a server * "MAY consider a client inactive if it has an IDLE command running" and log * it off. * * Nothing here marks anything `\Seen`: the mailbox is EXAMINEd and every fetch * downstream is `BODY.PEEK`. */ import type { InboundMailObserver, InboundWatcherSettings, MailboxWire, WatcherClock } from './ports.js'; /** * Whether an untagged line is worth ending the silence for. * * `* 0 RECENT` is not: a mailbox reporting no recent messages is answering a * question nobody asked, and waking on it would end every IDLE the moment it * began on servers that volunteer it. */ export declare function isIdleWakeLine(line: string): boolean; /** What a completed IDLE round observed. */ export interface IdleWakeSummary { /** Every untagged line seen between `IDLE` and its tagged completion. */ readonly lines: readonly string[]; /** An `EXISTS`, or a `RECENT` above zero, arrived. */ readonly mailboxGrew: boolean; /** An `EXPUNGE` or `VANISHED` arrived. */ readonly expunged: boolean; /** The server announced it is closing the connection. */ readonly bye: boolean; /** * Whether the caller should ask what is above the cursor. * * True when the mailbox grew, and ALSO true on every re-issue. The re-issue * sweep costs one `UID SEARCH` every twenty-seven minutes and closes the one * hole push cannot close by itself: a notification that was never sent, or * was sent while nothing could receive it. `EXPUNGE` alone never sets it, * a deletion cannot produce mail. */ readonly refetch: boolean; /** What ended the wait. */ readonly endedBy: 'untagged' | 'reissue' | 'abort' | 'error'; } /** How an IDLE round ended, from the loop's point of view. */ export type IdleRoundOutcome = /** The connection is healthy and the loop should issue another IDLE. */ 'continue' /** Shutdown was requested; the IDLE was ended cleanly. */ | 'stopped' /** The server refused `IDLE` itself. Fall back to polling; do not reconnect. */ | 'idle-refused' /** The socket failed. Reconnect on a backoff. */ | 'connection-lost' /** The liveness probe did not complete. The connection is dead. Rebuild it. */ | 'reissue-stalled'; export interface IdleRoundResult { readonly outcome: IdleRoundOutcome; readonly wake: IdleWakeSummary; readonly error: unknown; } export interface IdleRoundDeps { readonly wire: MailboxWire; readonly clock: WatcherClock; readonly settings: InboundWatcherSettings; readonly signal: AbortSignal; /** 0 for the first IDLE of a connection; used to name a stalled re-issue. */ readonly roundIndex: number; } /** * Run one `IDLE` … `DONE` cycle. * * The subscription is opened first and released last, so the collected lines * span the entire cycle including the `DONE` handshake, see point 2 in the * file header. `DONE` is written even when the wait ended in shutdown, and its * tagged completion is awaited WITHOUT the caller's abort signal: a shutdown * that skipped the completion would leave a half-finished command on a * connection we are about to say `LOGOUT` on, and would drop whatever arrived * in the handshake window on the way out. */ export declare function runIdleRound(deps: IdleRoundDeps): Promise; export type IdleLoopOutcome = 'stopped' | 'idle-refused' | 'connection-lost' | 'reissue-stalled' /** The caller's wake handler asked to stop, a read or a delivery failed. */ | 'wake-halted'; export interface IdleLoopResult { readonly outcome: IdleLoopOutcome; /** Completed IDLE rounds. */ readonly rounds: number; readonly error: unknown; } export interface IdleLoopDeps extends Omit { readonly observer?: InboundMailObserver | undefined; /** * Ask what is above the cursor. Returns `'continue'` to keep IDLEing and * `'halt'` when the caller must take the connection back, a refused fetch * or a message the sink would not accept. */ readonly onWake: (wake: IdleWakeSummary) => Promise<'continue' | 'halt'>; } /** * Hold an IDLE connection, waking to fetch and re-issuing on the timer, until * shutdown or until something the caller has to act on. * * The wake handler runs BETWEEN rounds, never during one: `UID SEARCH` and * `UID FETCH` cannot be issued while an `IDLE` is in flight, and the round has * already collected its tagged completion by the time this calls out. */ export declare function runIdleLoop(deps: IdleLoopDeps): Promise; //# sourceMappingURL=idle-watcher.d.ts.map