refactor(telegram-pairing): remove TTL expiry from pairing codes
Pairing codes no longer expire on a timer. They are consumed on match or invalidated by wrong guesses. Removes ttlMs/expiresAt/deadline from the pairing primitive, setup CLI, and tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,14 +21,8 @@ import {
|
|||||||
} from '../src/channels/telegram-pairing.js';
|
} from '../src/channels/telegram-pairing.js';
|
||||||
import { emitStatus } from './status.js';
|
import { emitStatus } from './status.js';
|
||||||
|
|
||||||
interface Args {
|
function parseArgs(args: string[]): PairingIntent {
|
||||||
intent: PairingIntent;
|
|
||||||
ttlMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseArgs(args: string[]): Args {
|
|
||||||
let intent: PairingIntent = 'main';
|
let intent: PairingIntent = 'main';
|
||||||
let ttlMs = 5 * 60 * 1000;
|
|
||||||
for (let i = 0; i < args.length; i++) {
|
for (let i = 0; i < args.length; i++) {
|
||||||
switch (args[i]) {
|
switch (args[i]) {
|
||||||
case '--intent': {
|
case '--intent': {
|
||||||
@@ -44,12 +38,9 @@ function parseArgs(args: string[]): Args {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case '--ttl-ms':
|
|
||||||
ttlMs = parseInt(args[++i] || '300000', 10);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { intent, ttlMs };
|
return intent;
|
||||||
}
|
}
|
||||||
|
|
||||||
function intentToString(intent: PairingIntent): string {
|
function intentToString(intent: PairingIntent): string {
|
||||||
@@ -58,7 +49,7 @@ function intentToString(intent: PairingIntent): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function run(args: string[]): Promise<void> {
|
export async function run(args: string[]): Promise<void> {
|
||||||
const { intent, ttlMs } = parseArgs(args);
|
const intent = parseArgs(args);
|
||||||
|
|
||||||
// Pairing reads/writes its JSON store under DATA_DIR; the DB isn't strictly
|
// Pairing reads/writes its JSON store under DATA_DIR; the DB isn't strictly
|
||||||
// required for the pairing primitive itself, but the inbound interceptor
|
// required for the pairing primitive itself, but the inbound interceptor
|
||||||
@@ -68,11 +59,10 @@ export async function run(args: string[]): Promise<void> {
|
|||||||
runMigrations(db);
|
runMigrations(db);
|
||||||
|
|
||||||
const MAX_REGENERATIONS = 5;
|
const MAX_REGENERATIONS = 5;
|
||||||
let record = await createPairing(intent, { ttlMs });
|
let record = await createPairing(intent);
|
||||||
emitStatus('PAIR_TELEGRAM_ISSUED', {
|
emitStatus('PAIR_TELEGRAM_ISSUED', {
|
||||||
CODE: record.code,
|
CODE: record.code,
|
||||||
INTENT: intentToString(intent),
|
INTENT: intentToString(intent),
|
||||||
EXPIRES_AT: record.expiresAt,
|
|
||||||
INSTRUCTIONS: `Send "${record.code}" from the Telegram chat you want to register (or "@<botname> ${record.code}" in a group with privacy on).`,
|
INSTRUCTIONS: `Send "${record.code}" from the Telegram chat you want to register (or "@<botname> ${record.code}" in a group with privacy on).`,
|
||||||
REMINDER_TO_ASSISTANT: `Your next user-visible message MUST include this CODE in plain text — the bash tool output this block is in gets collapsed in the UI.`,
|
REMINDER_TO_ASSISTANT: `Your next user-visible message MUST include this CODE in plain text — the bash tool output this block is in gets collapsed in the UI.`,
|
||||||
});
|
});
|
||||||
@@ -80,7 +70,6 @@ export async function run(args: string[]): Promise<void> {
|
|||||||
for (let regen = 0; regen <= MAX_REGENERATIONS; regen++) {
|
for (let regen = 0; regen <= MAX_REGENERATIONS; regen++) {
|
||||||
try {
|
try {
|
||||||
const consumed = await waitForPairing(record.code, {
|
const consumed = await waitForPairing(record.code, {
|
||||||
timeoutMs: ttlMs,
|
|
||||||
onAttempt: (a) => {
|
onAttempt: (a) => {
|
||||||
emitStatus('PAIR_TELEGRAM_ATTEMPT', {
|
emitStatus('PAIR_TELEGRAM_ATTEMPT', {
|
||||||
EXPECTED_CODE: record.code,
|
EXPECTED_CODE: record.code,
|
||||||
@@ -105,11 +94,10 @@ export async function run(args: string[]): Promise<void> {
|
|||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
const invalidated = /invalidated by wrong code/.test(message);
|
const invalidated = /invalidated by wrong code/.test(message);
|
||||||
if (invalidated && regen < MAX_REGENERATIONS) {
|
if (invalidated && regen < MAX_REGENERATIONS) {
|
||||||
record = await createPairing(intent, { ttlMs });
|
record = await createPairing(intent);
|
||||||
emitStatus('PAIR_TELEGRAM_NEW_CODE', {
|
emitStatus('PAIR_TELEGRAM_NEW_CODE', {
|
||||||
CODE: record.code,
|
CODE: record.code,
|
||||||
INTENT: intentToString(intent),
|
INTENT: intentToString(intent),
|
||||||
EXPIRES_AT: record.expiresAt,
|
|
||||||
REASON: 'previous code invalidated by wrong attempt',
|
REASON: 'previous code invalidated by wrong attempt',
|
||||||
REGENERATIONS_LEFT: MAX_REGENERATIONS - regen - 1,
|
REGENERATIONS_LEFT: MAX_REGENERATIONS - regen - 1,
|
||||||
INSTRUCTIONS: `Send "${record.code}" from the Telegram chat you want to register.`,
|
INSTRUCTIONS: `Send "${record.code}" from the Telegram chat you want to register.`,
|
||||||
|
|||||||
@@ -64,11 +64,10 @@ describe('extractCode', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('createPairing', () => {
|
describe('createPairing', () => {
|
||||||
it('generates a 4-digit code with TTL', async () => {
|
it('generates a 4-digit code', async () => {
|
||||||
const r = await createPairing('main', { ttlMs: 60_000 });
|
const r = await createPairing('main');
|
||||||
expect(r.code).toMatch(/^\d{4}$/);
|
expect(r.code).toMatch(/^\d{4}$/);
|
||||||
expect(r.status).toBe('pending');
|
expect(r.status).toBe('pending');
|
||||||
expect(Date.parse(r.expiresAt)).toBeGreaterThan(Date.now());
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not collide with active codes', async () => {
|
it('does not collide with active codes', async () => {
|
||||||
@@ -128,12 +127,13 @@ describe('tryConsume', () => {
|
|||||||
expect(second).toBeNull();
|
expect(second).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cannot consume an expired pairing', async () => {
|
it('cannot consume an invalidated pairing', async () => {
|
||||||
const r = await createPairing('main', { ttlMs: 1 });
|
const r = await createPairing('main');
|
||||||
await new Promise((res) => setTimeout(res, 10));
|
// Invalidate by sending a wrong code
|
||||||
|
await tryConsume({ text: '9999', botUsername: 'b', platformId: 'p', isGroup: false });
|
||||||
const out = await tryConsume({ text: `@b ${r.code}`, botUsername: 'b', platformId: 'p', isGroup: false });
|
const out = await tryConsume({ text: `@b ${r.code}`, botUsername: 'b', platformId: 'p', isGroup: false });
|
||||||
expect(out).toBeNull();
|
expect(out).toBeNull();
|
||||||
expect(getStatus(r.code)).toBe('expired');
|
expect(getStatus(r.code)).toBe('invalidated');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -145,7 +145,7 @@ describe('getStatus', () => {
|
|||||||
|
|
||||||
describe('waitForPairing', () => {
|
describe('waitForPairing', () => {
|
||||||
it('resolves when consumed', async () => {
|
it('resolves when consumed', async () => {
|
||||||
const r = await createPairing('main', { ttlMs: 5000 });
|
const r = await createPairing('main');
|
||||||
const p = waitForPairing(r.code, { pollMs: 50 });
|
const p = waitForPairing(r.code, { pollMs: 50 });
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
tryConsume({ text: `@b ${r.code}`, botUsername: 'b', platformId: 'tg:1', isGroup: true, name: 'Group' });
|
tryConsume({ text: `@b ${r.code}`, botUsername: 'b', platformId: 'tg:1', isGroup: true, name: 'Group' });
|
||||||
@@ -155,17 +155,21 @@ describe('waitForPairing', () => {
|
|||||||
expect(consumed.consumed?.name).toBe('Group');
|
expect(consumed.consumed?.name).toBe('Group');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects on expiry', async () => {
|
it('rejects on invalidation', async () => {
|
||||||
const r = await createPairing('main', { ttlMs: 100 });
|
const r = await createPairing('main');
|
||||||
await expect(waitForPairing(r.code, { pollMs: 30 })).rejects.toThrow(/expired/);
|
const waiter = waitForPairing(r.code, { pollMs: 30 });
|
||||||
|
setTimeout(() => {
|
||||||
|
tryConsume({ text: '0000', botUsername: 'b', platformId: 'tg:1', isGroup: false });
|
||||||
|
}, 60);
|
||||||
|
await expect(waiter).rejects.toThrow(/invalidated/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('replace-by-default', () => {
|
describe('replace-by-default', () => {
|
||||||
it('supersedes an existing pending pairing with the same intent', async () => {
|
it('supersedes an existing pending pairing with the same intent', async () => {
|
||||||
const first = await createPairing('main', { ttlMs: 60_000 });
|
const first = await createPairing('main');
|
||||||
const second = await createPairing('main', { ttlMs: 60_000 });
|
const second = await createPairing('main');
|
||||||
expect(getStatus(first.code)).toBe('expired');
|
expect(getStatus(first.code)).toBe('invalidated');
|
||||||
expect(getStatus(second.code)).toBe('pending');
|
expect(getStatus(second.code)).toBe('pending');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -176,18 +180,18 @@ describe('replace-by-default', () => {
|
|||||||
expect(getStatus(b.code)).toBe('pending');
|
expect(getStatus(b.code)).toBe('pending');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('causes waitForPairing on the old code to reject as expired', async () => {
|
it('causes waitForPairing on the old code to reject as invalidated', async () => {
|
||||||
const first = await createPairing('main', { ttlMs: 60_000 });
|
const first = await createPairing('main');
|
||||||
const waiter = waitForPairing(first.code, { pollMs: 30 });
|
const waiter = waitForPairing(first.code, { pollMs: 30 });
|
||||||
await new Promise((r) => setTimeout(r, 50));
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
await createPairing('main', { ttlMs: 60_000 });
|
await createPairing('main');
|
||||||
await expect(waiter).rejects.toThrow(/expired/);
|
await expect(waiter).rejects.toThrow(/invalidated/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('attempt tracking', () => {
|
describe('attempt tracking', () => {
|
||||||
it('fires onAttempt for a wrong code, invalidates the pairing, and rejects the waiter', async () => {
|
it('fires onAttempt for a wrong code, invalidates the pairing, and rejects the waiter', async () => {
|
||||||
const r = await createPairing('main', { ttlMs: 5000 });
|
const r = await createPairing('main');
|
||||||
const attempts: string[] = [];
|
const attempts: string[] = [];
|
||||||
const waiter = waitForPairing(r.code, {
|
const waiter = waitForPairing(r.code, {
|
||||||
pollMs: 30,
|
pollMs: 30,
|
||||||
@@ -198,11 +202,11 @@ describe('attempt tracking', () => {
|
|||||||
}, 60);
|
}, 60);
|
||||||
await expect(waiter).rejects.toThrow(/invalidated by wrong code \(9999\)/);
|
await expect(waiter).rejects.toThrow(/invalidated by wrong code \(9999\)/);
|
||||||
expect(attempts).toEqual(['9999']);
|
expect(attempts).toEqual(['9999']);
|
||||||
expect(getStatus(r.code)).toBe('expired');
|
expect(getStatus(r.code)).toBe('invalidated');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a correct code consumes without firing onAttempt', async () => {
|
it('a correct code consumes without firing onAttempt', async () => {
|
||||||
const r = await createPairing('main', { ttlMs: 5000 });
|
const r = await createPairing('main');
|
||||||
const attempts: string[] = [];
|
const attempts: string[] = [];
|
||||||
const waiter = waitForPairing(r.code, {
|
const waiter = waitForPairing(r.code, {
|
||||||
pollMs: 30,
|
pollMs: 30,
|
||||||
@@ -217,7 +221,7 @@ describe('attempt tracking', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('ignores non-code messages and keeps the pairing pending', async () => {
|
it('ignores non-code messages and keeps the pairing pending', async () => {
|
||||||
const r = await createPairing('main', { ttlMs: 5000 });
|
const r = await createPairing('main');
|
||||||
await tryConsume({ text: 'hello there', botUsername: 'b', platformId: 'p', isGroup: false });
|
await tryConsume({ text: 'hello there', botUsername: 'b', platformId: 'p', isGroup: false });
|
||||||
const after = getPairing(r.code);
|
const after = getPairing(r.code);
|
||||||
expect(after?.status).toBe('pending');
|
expect(after?.status).toBe('pending');
|
||||||
@@ -225,7 +229,7 @@ describe('attempt tracking', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('a second code attempt after invalidation does not match', async () => {
|
it('a second code attempt after invalidation does not match', async () => {
|
||||||
const r = await createPairing('main', { ttlMs: 5000 });
|
const r = await createPairing('main');
|
||||||
await tryConsume({ text: '9999', botUsername: 'b', platformId: 'p', isGroup: false });
|
await tryConsume({ text: '9999', botUsername: 'b', platformId: 'p', isGroup: false });
|
||||||
const retry = await tryConsume({ text: r.code, botUsername: 'b', platformId: 'p', isGroup: false });
|
const retry = await tryConsume({ text: r.code, botUsername: 'b', platformId: 'p', isGroup: false });
|
||||||
expect(retry).toBeNull();
|
expect(retry).toBeNull();
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { DATA_DIR } from '../config.js';
|
|||||||
import { log } from '../log.js';
|
import { log } from '../log.js';
|
||||||
|
|
||||||
export type PairingIntent = 'main' | { kind: 'wire-to'; folder: string } | { kind: 'new-agent'; folder: string };
|
export type PairingIntent = 'main' | { kind: 'wire-to'; folder: string } | { kind: 'new-agent'; folder: string };
|
||||||
export type PairingStatus = 'pending' | 'consumed' | 'expired' | 'unknown';
|
export type PairingStatus = 'pending' | 'consumed' | 'invalidated' | 'unknown';
|
||||||
|
|
||||||
export interface ConsumedDetails {
|
export interface ConsumedDetails {
|
||||||
platformId: string;
|
platformId: string;
|
||||||
@@ -42,7 +42,6 @@ export interface PairingRecord {
|
|||||||
code: string;
|
code: string;
|
||||||
intent: PairingIntent;
|
intent: PairingIntent;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
expiresAt: string;
|
|
||||||
status: Exclude<PairingStatus, 'unknown'>;
|
status: Exclude<PairingStatus, 'unknown'>;
|
||||||
consumed?: ConsumedDetails;
|
consumed?: ConsumedDetails;
|
||||||
/** Recent pairing attempts observed while this record was pending. Capped. */
|
/** Recent pairing attempts observed while this record was pending. Capped. */
|
||||||
@@ -60,7 +59,7 @@ interface Store {
|
|||||||
pairings: PairingRecord[];
|
pairings: PairingRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_TTL_MS = 5 * 60 * 1000;
|
/** Pairing codes do not expire — they are consumed on match or invalidated by wrong guesses. */
|
||||||
const FILE_NAME = 'telegram-pairings.json';
|
const FILE_NAME = 'telegram-pairings.json';
|
||||||
|
|
||||||
let storePathOverride: string | null = null;
|
let storePathOverride: string | null = null;
|
||||||
@@ -98,15 +97,11 @@ function writeStore(store: Store): void {
|
|||||||
fs.renameSync(tmp, p);
|
fs.renameSync(tmp, p);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sweep(store: Store, now: number): boolean {
|
/** Clean up old consumed/invalidated records (keep last 50). */
|
||||||
let changed = false;
|
function sweep(store: Store): boolean {
|
||||||
for (const r of store.pairings) {
|
if (store.pairings.length <= 50) return false;
|
||||||
if (r.status === 'pending' && Date.parse(r.expiresAt) <= now) {
|
store.pairings = store.pairings.slice(-50);
|
||||||
r.status = 'expired';
|
return true;
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return changed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateCode(active: Set<string>): string {
|
function generateCode(active: Set<string>): string {
|
||||||
@@ -120,36 +115,29 @@ function generateCode(active: Set<string>): string {
|
|||||||
throw new Error('Could not allocate a free pairing code (too many active).');
|
throw new Error('Could not allocate a free pairing code (too many active).');
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreatePairingOptions {
|
export async function createPairing(intent: PairingIntent): Promise<PairingRecord> {
|
||||||
ttlMs?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createPairing(intent: PairingIntent, opts: CreatePairingOptions = {}): Promise<PairingRecord> {
|
|
||||||
const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
||||||
return withLock(() => {
|
return withLock(() => {
|
||||||
const store = readStore();
|
const store = readStore();
|
||||||
sweep(store, Date.now());
|
sweep(store);
|
||||||
// Replace-by-default: a new pairing for an intent supersedes any existing
|
// Replace-by-default: a new pairing for an intent supersedes any existing
|
||||||
// pending pairing for the same intent. Old waitForPairing calls observe
|
// pending pairing for the same intent. Old waitForPairing calls observe
|
||||||
// `expired` and exit on their own.
|
// `invalidated` and exit on their own.
|
||||||
for (const r of store.pairings) {
|
for (const r of store.pairings) {
|
||||||
if (r.status === 'pending' && intentEquals(r.intent, intent)) {
|
if (r.status === 'pending' && intentEquals(r.intent, intent)) {
|
||||||
r.status = 'expired';
|
r.status = 'invalidated';
|
||||||
log.info('Pairing superseded by new request', { code: r.code, intent });
|
log.info('Pairing superseded by new request', { code: r.code, intent });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const active = new Set(store.pairings.filter((r) => r.status === 'pending').map((r) => r.code));
|
const active = new Set(store.pairings.filter((r) => r.status === 'pending').map((r) => r.code));
|
||||||
const now = new Date();
|
|
||||||
const record: PairingRecord = {
|
const record: PairingRecord = {
|
||||||
code: generateCode(active),
|
code: generateCode(active),
|
||||||
intent,
|
intent,
|
||||||
createdAt: now.toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
expiresAt: new Date(now.getTime() + ttl).toISOString(),
|
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
};
|
};
|
||||||
store.pairings.push(record);
|
store.pairings.push(record);
|
||||||
writeStore(store);
|
writeStore(store);
|
||||||
log.info('Pairing created', { code: record.code, intent, expiresAt: record.expiresAt });
|
log.info('Pairing created', { code: record.code, intent });
|
||||||
return record;
|
return record;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -195,7 +183,7 @@ export async function tryConsume(input: ConsumeInput): Promise<PairingRecord | n
|
|||||||
return withLock(() => {
|
return withLock(() => {
|
||||||
const store = readStore();
|
const store = readStore();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
sweep(store, now);
|
sweep(store);
|
||||||
const record = store.pairings.find((r) => r.code === code && r.status === 'pending');
|
const record = store.pairings.find((r) => r.code === code && r.status === 'pending');
|
||||||
if (!record) {
|
if (!record) {
|
||||||
// Miss: record the attempt on every currently-pending record so each
|
// Miss: record the attempt on every currently-pending record so each
|
||||||
@@ -211,9 +199,9 @@ export async function tryConsume(input: ConsumeInput): Promise<PairingRecord | n
|
|||||||
if (r.status !== 'pending') continue;
|
if (r.status !== 'pending') continue;
|
||||||
r.attempts = [...(r.attempts ?? []), attempt].slice(-MAX_ATTEMPTS_PER_RECORD);
|
r.attempts = [...(r.attempts ?? []), attempt].slice(-MAX_ATTEMPTS_PER_RECORD);
|
||||||
// One attempt per code. A wrong guess invalidates the pairing
|
// One attempt per code. A wrong guess invalidates the pairing
|
||||||
// immediately — pair-telegram observes the `expired` signal and
|
// immediately — pair-telegram observes the `invalidated` signal and
|
||||||
// auto-issues a fresh code (up to a retry cap).
|
// auto-issues a fresh code (up to a retry cap).
|
||||||
r.status = 'expired';
|
r.status = 'invalidated';
|
||||||
recorded = true;
|
recorded = true;
|
||||||
}
|
}
|
||||||
writeStore(store);
|
writeStore(store);
|
||||||
@@ -242,7 +230,7 @@ export async function tryConsume(input: ConsumeInput): Promise<PairingRecord | n
|
|||||||
|
|
||||||
export function getStatus(code: string): PairingStatus {
|
export function getStatus(code: string): PairingStatus {
|
||||||
const store = readStore();
|
const store = readStore();
|
||||||
sweep(store, Date.now());
|
sweep(store);
|
||||||
const r = store.pairings.find((p) => p.code === code);
|
const r = store.pairings.find((p) => p.code === code);
|
||||||
if (!r) return 'unknown';
|
if (!r) return 'unknown';
|
||||||
return r.status;
|
return r.status;
|
||||||
@@ -250,13 +238,11 @@ export function getStatus(code: string): PairingStatus {
|
|||||||
|
|
||||||
export function getPairing(code: string): PairingRecord | null {
|
export function getPairing(code: string): PairingRecord | null {
|
||||||
const store = readStore();
|
const store = readStore();
|
||||||
sweep(store, Date.now());
|
sweep(store);
|
||||||
return store.pairings.find((p) => p.code === code) ?? null;
|
return store.pairings.find((p) => p.code === code) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WaitForPairingOptions {
|
export interface WaitForPairingOptions {
|
||||||
/** Total time to wait. Defaults to the pairing's own TTL (read on each tick). */
|
|
||||||
timeoutMs?: number;
|
|
||||||
/** Polling interval as a fallback when fs.watch misses an event. */
|
/** Polling interval as a fallback when fs.watch misses an event. */
|
||||||
pollMs?: number;
|
pollMs?: number;
|
||||||
/** Fires once per new attempt recorded against this pairing (misses only). */
|
/** Fires once per new attempt recorded against this pairing (misses only). */
|
||||||
@@ -264,16 +250,14 @@ export interface WaitForPairingOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve when the pairing is consumed; reject when it expires or the timeout
|
* Resolve when the pairing is consumed; reject when it is invalidated
|
||||||
* elapses. Uses fs.watch as the primary signal with a slow poll fallback —
|
* (wrong code guess). Waits indefinitely — codes do not expire.
|
||||||
* fs.watch is unreliable across rename-replace on some filesystems.
|
* Uses fs.watch as the primary signal with a slow poll fallback.
|
||||||
*/
|
*/
|
||||||
export async function waitForPairing(code: string, opts: WaitForPairingOptions = {}): Promise<PairingRecord> {
|
export async function waitForPairing(code: string, opts: WaitForPairingOptions = {}): Promise<PairingRecord> {
|
||||||
const pollMs = opts.pollMs ?? 1000;
|
const pollMs = opts.pollMs ?? 1000;
|
||||||
const start = Date.now();
|
|
||||||
const initial = getPairing(code);
|
const initial = getPairing(code);
|
||||||
if (!initial) throw new Error(`Unknown pairing code: ${code}`);
|
if (!initial) throw new Error(`Unknown pairing code: ${code}`);
|
||||||
const deadline = start + (opts.timeoutMs ?? Math.max(0, Date.parse(initial.expiresAt) - start));
|
|
||||||
|
|
||||||
return new Promise<PairingRecord>((resolve, reject) => {
|
return new Promise<PairingRecord>((resolve, reject) => {
|
||||||
let watcher: fs.FSWatcher | null = null;
|
let watcher: fs.FSWatcher | null = null;
|
||||||
@@ -320,16 +304,15 @@ export async function waitForPairing(code: string, opts: WaitForPairingOptions =
|
|||||||
resolve(r);
|
resolve(r);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (r.status === 'expired' || Date.now() >= deadline) {
|
if (r.status === 'invalidated') {
|
||||||
cleanup();
|
cleanup();
|
||||||
const lastMiss = r.attempts
|
const lastMiss = r.attempts
|
||||||
?.slice()
|
?.slice()
|
||||||
.reverse()
|
.reverse()
|
||||||
.find((a) => !a.matched);
|
.find((a) => !a.matched);
|
||||||
const reason = lastMiss
|
reject(new Error(
|
||||||
? `Pairing ${code} invalidated by wrong code (${lastMiss.candidate})`
|
`Pairing ${code} invalidated by wrong code${lastMiss ? ` (${lastMiss.candidate})` : ''}`
|
||||||
: `Pairing ${code} expired`;
|
));
|
||||||
reject(new Error(reason));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user