Skip to content

Commit 543386a

Browse files
alistair3149claude
andauthored
Measure elapsed time with a clock that cannot step (#568)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent c10735b commit 543386a

22 files changed

Lines changed: 247 additions & 50 deletions

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Project context for AI coding agents working on this repo. For human users, star
66

77
- `src/tools/` — one file per non-extension MCP tool (descriptor + handler + registration).
88
- `src/tools/extensions/<id>/` — extension packs: tools gated on a specific MediaWiki extension (SMW / Bucket / Cargo / …), grouped under a per-pack module.
9-
- `src/runtime/` — context, dispatcher, register, reconcile, logger, constants, request-scoped context, auth-shape classifier.
9+
- `src/runtime/` — context, dispatcher, register, reconcile, logger, monotonic clock, constants, request-scoped context, auth-shape classifier.
1010
- `src/wikis/` — wiki registry, selection, mwn provider, discovery, error sanitiser.
1111
- `src/transport/` — stdio and streamable HTTP entry points, SSRF/upload guards, low-level HTTP helpers.
1212
- `src/auth/` — OAuth for MediaWiki, in two roles (client to a wiki, and the hosted authorization-server proxy). See [src/auth/README.md](src/auth/README.md).

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
2626
- `delete-page` no longer sends an empty deletion reason when the wiki sets `attributeEdits: false` and the call gave no comment. MediaWiki recorded that empty reason verbatim, leaving a blank deletion log entry; with no reason sent at all it can autogenerate its own `content was: …` reason instead.
2727
- `update-page` no longer advertises itself as idempotent: in `mode='append'` and `mode='prepend'` it never was, so a client replaying a call whose result never arrived adds the content a second time. A replace resends the same content rather than adding to it.
2828
- `upload-file-from-url` and `update-file-from-url` no longer leak a connection when they refuse a source URL whose declared size is over `MCP_UPLOAD_MAX_BYTES`. Each refused call held one connection open for as long as the server ran.
29+
- A host correcting its clock no longer changes what the server does with elapsed time: rate-limit allowances, the shutdown grace window, the readiness and extension-detection caches, and the window a hosted sign-in has to finish. Measured against the wall clock, a backwards NTP step could refuse a caller that had barely touched its rate-limit allowance with a `Retry-After` of up to an hour, and a forwards step could end a graceful shutdown early, aborting the tool calls it was waiting for.
2930

3031
## [0.16.0] - 2026-07-30
3132

src/auth/authorizationServer/cimd.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ClientRecord } from './proxyStore.ts';
22
import { isLoopbackHost } from './redirectPolicy.ts';
3+
import { monotonicNow } from '../../runtime/clock.ts';
34

45
export class CimdValidationError extends Error {
56
public constructor(message: string) {
@@ -219,7 +220,7 @@ export class CimdResolver {
219220
public constructor(
220221
private isHostAllowed: (host: string) => boolean,
221222
private fetcher: (url: string) => Promise<CimdFetchResult>,
222-
private now: () => number = Date.now,
223+
private now: () => number = monotonicNow,
223224
private maxEntries: number = CIMD_CACHE_MAX_ENTRIES,
224225
) {}
225226

src/auth/authorizationServer/proxyStore.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { randomUUID } from 'node:crypto';
2+
import { monotonicNow } from '../../runtime/clock.ts';
23

34
export interface ClientRecord {
45
clientId: string;
@@ -99,12 +100,14 @@ export class InMemoryProxyStore implements ProxyStore {
99100
private refreshing = new Set<string>();
100101

101102
public constructor(
102-
private now: () => number = Date.now,
103+
private elapsedNow: () => number = monotonicNow,
103104
private maxClients: number = DEFAULT_MAX_CLIENTS,
104105
) {}
105106

106107
public putClient(c: Omit<ClientRecord, 'clientId' | 'createdAt'>): ClientRecord {
107-
const rec: ClientRecord = { ...c, clientId: `mcp-${randomUUID()}`, createdAt: this.now() };
108+
// Not elapsedNow(): register.ts publishes createdAt as client_id_issued_at,
109+
// which a client reads as a Unix timestamp.
110+
const rec: ClientRecord = { ...c, clientId: `mcp-${randomUUID()}`, createdAt: Date.now() };
108111
// FIFO eviction: drop the oldest registration before exceeding the cap.
109112
// Map preserves insertion order, so the first key is the oldest.
110113
while (this.clients.size >= this.maxClients) {
@@ -127,15 +130,15 @@ export class InMemoryProxyStore implements ProxyStore {
127130
}
128131

129132
public putTransaction(id: string, t: TransactionRecord, ttlMs = TXN_TTL_MS): void {
130-
this.txns.set(id, { value: t, expiresAt: this.now() + ttlMs });
133+
this.txns.set(id, { value: t, expiresAt: this.elapsedNow() + ttlMs });
131134
}
132135

133136
public getTransaction(id: string): TransactionRecord | undefined {
134137
const e = this.txns.get(id);
135138
if (!e) {
136139
return undefined;
137140
}
138-
if (e.expiresAt < this.now()) {
141+
if (e.expiresAt < this.elapsedNow()) {
139142
this.txns.delete(id);
140143
return undefined;
141144
}
@@ -147,13 +150,13 @@ export class InMemoryProxyStore implements ProxyStore {
147150
}
148151

149152
public putCode(code: string, r: CodeRecord, ttlMs = CODE_TTL_MS): void {
150-
this.codes.set(code, { value: r, expiresAt: this.now() + ttlMs });
153+
this.codes.set(code, { value: r, expiresAt: this.elapsedNow() + ttlMs });
151154
}
152155

153156
public consumeCode(code: string): CodeRecord | undefined {
154157
const e = this.codes.get(code);
155158
this.codes.delete(code); // one-time regardless of expiry
156-
if (!e || e.expiresAt < this.now()) {
159+
if (!e || e.expiresAt < this.elapsedNow()) {
157160
return undefined;
158161
}
159162
return e.value;

src/auth/authorizationServer/proxyStorePersistence.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
22
import * as path from 'node:path';
3-
import { performance } from 'node:perf_hooks';
43
import { isErrnoException } from '../../errors/isErrnoException.ts';
54
import { recordStoreFlush, recordStoreFlushFailure } from '../../runtime/metrics.ts';
65
import { getProxyStorePath } from '../paths.ts';
76
import type { ProxyConfig } from './proxyConfig.ts';
87
import { deriveKey, decrypt, encrypt } from './proxyStoreCrypto.ts';
8+
import { monotonicNow } from '../../runtime/clock.ts';
99
import {
1010
InMemoryProxyStore,
1111
type ClientRecord,
@@ -184,7 +184,7 @@ export class PersistentProxyStore implements ProxyStore {
184184
}
185185

186186
private flushSync(): void {
187-
const start = performance.now();
187+
const start = monotonicNow();
188188
try {
189189
const json = JSON.stringify(this.inner.snapshotDurable());
190190
const blob = encrypt(this.key, Buffer.from(json, 'utf8'));
@@ -193,7 +193,7 @@ export class PersistentProxyStore implements ProxyStore {
193193
writeFileSync(tmp, blob, { mode: 0o600 });
194194
renameSync(tmp, this.file);
195195
this.dirty = false;
196-
recordStoreFlush(performance.now() - start);
196+
recordStoreFlush(monotonicNow() - start);
197197
} catch (err: unknown) {
198198
// Best-effort durability: a disk failure must not break the live request. The
199199
// record stays valid in memory; persistence is re-attempted by the next

src/auth/browserAuth.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { randomBytes } from 'node:crypto';
44
import type { AddressInfo } from 'node:net';
55
import open from 'open';
66
import { logger } from '../runtime/logger.ts';
7+
import { monotonicNow } from '../runtime/clock.ts';
78
import { fetchMetadata } from './metadata.ts';
89
import type { WikiSlice } from './metadata.ts';
910
import { randomVerifier, s256 } from './pkce.ts';
@@ -67,7 +68,7 @@ export function browserAuth(wikiKey: string, ctx: BrowserAuthCtx): Promise<strin
6768
}
6869

6970
async function doBrowserAuth(wikiKey: string, ctx: BrowserAuthCtx): Promise<string> {
70-
const started = Date.now();
71+
const started = monotonicNow();
7172
const timeoutMs = ctx.timeoutMs ?? DEFAULT_TIMEOUT_MS;
7273

7374
const metadata = await fetchMetadata(wikiKey, ctx.wiki);
@@ -144,7 +145,7 @@ async function doBrowserAuth(wikiKey: string, ctx: BrowserAuthCtx): Promise<stri
144145
event: 'oauth_login_failed',
145146
wiki: wikiKey,
146147
reason,
147-
duration_ms: Date.now() - started,
148+
duration_ms: Math.round(monotonicNow() - started),
148149
});
149150
if (err instanceof OAuthFlowError) {
150151
throw new BrowserAuthError(reason, err.message);
@@ -170,7 +171,7 @@ async function doBrowserAuth(wikiKey: string, ctx: BrowserAuthCtx): Promise<stri
170171
logger.info('', {
171172
event: 'oauth_login_completed',
172173
wiki: wikiKey,
173-
duration_ms: Date.now() - started,
174+
duration_ms: Math.round(monotonicNow() - started),
174175
});
175176

176177
return tok.access_token;

src/auth/metadata.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// src/auth/metadata.ts
22
import { logger } from '../runtime/logger.ts';
3+
import { monotonicNow } from '../runtime/clock.ts';
34
import { mwOauth2AuthorizeEndpoint, mwOauth2TokenEndpoint } from './mwOauth2Endpoints.ts';
45

56
// The authorization-server metadata of an upstream wiki, as discovered from its
@@ -56,7 +57,7 @@ export function fetchMetadata(wikiKey: string, wiki: WikiSlice): Promise<Upstrea
5657
}
5758

5859
async function doFetch(wikiKey: string, wiki: WikiSlice): Promise<UpstreamAsMetadata> {
59-
const started = Date.now();
60+
const started = monotonicNow();
6061
const origin = `${wiki.server}/.well-known/oauth-authorization-server`;
6162
const pathed = `${wiki.server}/.well-known/oauth-authorization-server${wiki.scriptpath}/rest.php/oauth2`;
6263

@@ -89,7 +90,7 @@ async function doFetch(wikiKey: string, wiki: WikiSlice): Promise<UpstreamAsMeta
8990
wiki: wikiKey,
9091
outcome: 'success',
9192
source: 'synthesized',
92-
duration_ms: Date.now() - started,
93+
duration_ms: Math.round(monotonicNow() - started),
9394
});
9495
return synthesized;
9596
}
@@ -163,7 +164,7 @@ function finalize(
163164
wiki: wikiKey,
164165
outcome: 'success',
165166
source: md.source,
166-
duration_ms: Date.now() - started,
167+
duration_ms: Math.round(monotonicNow() - started),
167168
});
168169
return { ...md, issuer };
169170
}

src/runtime/clock.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Elapsed time — a cache TTL, a grace period, a logged duration — is measured
2+
// with this rather than Date.now, which steps in both directions when a host
3+
// corrects its clock. It counts milliseconds from process start and only ever
4+
// moves forwards.
5+
//
6+
// Date.now stays correct for a point in time that means something outside this
7+
// run of the process: a JWT claim, a timestamp that is published, or a token
8+
// expiry that has to still mean something after a restart.
9+
export function monotonicNow(): number {
10+
// performance.now reads `this`, so it cannot be passed as a bare callback.
11+
return performance.now();
12+
}

src/runtime/dispatcher.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
import { acquireToken } from '../auth/acquireToken.ts';
1616
import { structuredResult } from '../results/response.ts';
1717
import { checkWikiCapability } from './wikiCapability.ts';
18+
import { monotonicNow } from './clock.ts';
1819

1920
// Tools that operate on server-local state (the wiki registry, the OAuth token
2021
// store) rather than a wiki's API. They must not be blocked by an OAuth gate
@@ -100,7 +101,7 @@ async function runDispatchInner<TSchema extends ZodRawShape, TCtx extends ToolCo
100101
args: z.infer<z.ZodObject<TSchema>>,
101102
resolvedKey?: string,
102103
): Promise<CallToolResult> {
103-
const started = performance.now();
104+
const started = monotonicNow();
104105
let outcome: ToolOutcome = 'success';
105106
let errorText: string | undefined;
106107
let upstreamStatus: number | undefined;

src/runtime/instrument.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
22
import type { CallToolResult } from '@modelcontextprotocol/server';
33
import { emitTelemetryEvent } from './logger.ts';
44
import { recordToolCall } from './metrics.ts';
5+
import { monotonicNow } from './clock.ts';
56
import type { ErrorCategory } from '../errors/classifyError.ts';
67

78
// `cancelled` sits alongside ErrorCategory rather than inside it: the caller
@@ -126,7 +127,7 @@ export function emitToolCall<TArgs>(opts: EmitToolCallOptions<TArgs>): void {
126127
const level = levelFor(opts.outcome);
127128
const targetValue = safeTarget(opts.target, opts.args);
128129
const truncated = opts.outcome === 'success' ? detectTruncation(opts.result) : false;
129-
const durationMs = Math.round(performance.now() - opts.started);
130+
const durationMs = Math.round(monotonicNow() - opts.started);
130131
// Snake-case keys are required by the structured log schema.
131132
const data: Record<string, unknown> = {
132133
event: 'tool_call',

0 commit comments

Comments
 (0)