@effectstream/sync 0.200.5 → 0.300.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -4
- package/package.json +9 -9
- package/src/sync-protocols/midnight/tip.ts +295 -0
- package/src/sync-protocols/mod.ts +3 -0
- package/src/sync-protocols/ntp/fetcher.ts +16 -19
- package/src/sync-protocols/ntp/tip.ts +170 -0
- package/test/examples.test.ts +4 -0
- package/test/midnight-tip.test.ts +694 -0
- package/test/ntp-fetcher-case.ts +248 -0
- package/test/ntp-fetcher.test.ts +69 -0
- package/test/ntp-tip.test.ts +228 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { createSocket, type RemoteInfo, type Socket } from "node:dgram";
|
|
3
|
+
import { run } from "effection";
|
|
4
|
+
import {
|
|
5
|
+
ConfigNetworkType,
|
|
6
|
+
ConfigSyncProtocolType,
|
|
7
|
+
} from "@effectstream/config";
|
|
8
|
+
import { NtpFetcher } from "../src/sync-protocols/ntp/fetcher.ts";
|
|
9
|
+
import { NtpSyncState } from "../src/sync-protocols/ntp/state.ts";
|
|
10
|
+
|
|
11
|
+
type Responder = (request: Buffer, remote: RemoteInfo, socket: Socket) => void;
|
|
12
|
+
|
|
13
|
+
async function closeSocket(socket: Socket): Promise<void> {
|
|
14
|
+
await new Promise<void>((resolve) => {
|
|
15
|
+
try {
|
|
16
|
+
socket.close(() => resolve());
|
|
17
|
+
} catch {
|
|
18
|
+
resolve();
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function startUdp(responder: Responder) {
|
|
24
|
+
let lastError: unknown;
|
|
25
|
+
for (let attempt = 0; attempt < 64; attempt++) {
|
|
26
|
+
const socket = createSocket("udp4");
|
|
27
|
+
try {
|
|
28
|
+
await new Promise<void>((resolve, reject) => {
|
|
29
|
+
socket.once("error", reject);
|
|
30
|
+
socket.bind(0, "127.0.0.1", () => {
|
|
31
|
+
socket.removeListener("error", reject);
|
|
32
|
+
resolve();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
const port = socket.address().port;
|
|
36
|
+
if (port <= 10_000) {
|
|
37
|
+
await closeSocket(socket);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
let hits = 0;
|
|
41
|
+
const clientPorts = new Set<number>();
|
|
42
|
+
socket.on("message", (message, remote) => {
|
|
43
|
+
hits += 1;
|
|
44
|
+
clientPorts.add(remote.port);
|
|
45
|
+
responder(message, remote, socket);
|
|
46
|
+
});
|
|
47
|
+
return {
|
|
48
|
+
port,
|
|
49
|
+
hits: () => hits,
|
|
50
|
+
clientPorts: () => [...clientPorts],
|
|
51
|
+
close: () => closeSocket(socket),
|
|
52
|
+
};
|
|
53
|
+
} catch (error) {
|
|
54
|
+
lastError = error;
|
|
55
|
+
await closeSocket(socket);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
throw lastError ?? new Error("no OS-selected UDP port above 10000");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function assertPortReusable(port: number): Promise<void> {
|
|
62
|
+
const socket = createSocket("udp4");
|
|
63
|
+
await new Promise<void>((resolve, reject) => {
|
|
64
|
+
socket.once("error", reject);
|
|
65
|
+
socket.bind(port, "127.0.0.1", () => {
|
|
66
|
+
socket.removeListener("error", reject);
|
|
67
|
+
resolve();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
await closeSocket(socket);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function writeTimestamp(buffer: Buffer, offset: number, unixMs: number): void {
|
|
74
|
+
const ntpMs = unixMs + 2_208_988_800_000;
|
|
75
|
+
const seconds = Math.floor(ntpMs / 1_000);
|
|
76
|
+
const fraction = Math.floor(((ntpMs % 1_000) / 1_000) * 2 ** 32);
|
|
77
|
+
buffer.writeUInt32BE(seconds >>> 0, offset);
|
|
78
|
+
buffer.writeUInt32BE(fraction >>> 0, offset + 4);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function responseFor(request: Buffer, clockOffsetMs = 0): Buffer {
|
|
82
|
+
const response = Buffer.alloc(48);
|
|
83
|
+
response[0] = (4 << 3) | 4;
|
|
84
|
+
response[1] = 1;
|
|
85
|
+
response[2] = 4;
|
|
86
|
+
response[3] = 0xec;
|
|
87
|
+
response.write("LOCL", 12, 4, "ascii");
|
|
88
|
+
const now = Date.now() + clockOffsetMs;
|
|
89
|
+
writeTimestamp(response, 16, now - 1_000);
|
|
90
|
+
request.copy(response, 24, 40, 48);
|
|
91
|
+
writeTimestamp(response, 32, now);
|
|
92
|
+
writeTimestamp(response, 40, now);
|
|
93
|
+
return response;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const responder =
|
|
97
|
+
(clockOffsetMs = 0): Responder =>
|
|
98
|
+
(request, remote, socket) => {
|
|
99
|
+
socket.send(
|
|
100
|
+
responseFor(request, clockOffsetMs),
|
|
101
|
+
remote.port,
|
|
102
|
+
remote.address,
|
|
103
|
+
);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
function config(servers?: string[]) {
|
|
107
|
+
return {
|
|
108
|
+
networkType: ConfigNetworkType.NTP,
|
|
109
|
+
syncProtocolType: ConfigSyncProtocolType.NTP_MAIN,
|
|
110
|
+
network: {
|
|
111
|
+
name: "controlled-clock",
|
|
112
|
+
type: ConfigNetworkType.NTP,
|
|
113
|
+
blockTimeMS: 1_000,
|
|
114
|
+
startTime: 0,
|
|
115
|
+
servers,
|
|
116
|
+
},
|
|
117
|
+
syncProtocol: {
|
|
118
|
+
name: "controlled-clock",
|
|
119
|
+
type: ConfigSyncProtocolType.NTP_MAIN,
|
|
120
|
+
startBlockHeight: 42,
|
|
121
|
+
pollingInterval: 1_000,
|
|
122
|
+
stepSize: 10,
|
|
123
|
+
},
|
|
124
|
+
primitives: [],
|
|
125
|
+
} as any;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function withTwoServers(
|
|
129
|
+
action: (
|
|
130
|
+
a: Awaited<ReturnType<typeof startUdp>>,
|
|
131
|
+
b: Awaited<ReturnType<typeof startUdp>>,
|
|
132
|
+
) => Promise<void>,
|
|
133
|
+
) {
|
|
134
|
+
const a = await startUdp(responder());
|
|
135
|
+
const b = await startUdp(responder(100_000));
|
|
136
|
+
try {
|
|
137
|
+
await action(a, b);
|
|
138
|
+
} finally {
|
|
139
|
+
const clientPorts = [...a.clientPorts(), ...b.clientPorts()];
|
|
140
|
+
await a.close();
|
|
141
|
+
await b.close();
|
|
142
|
+
await assertPortReusable(a.port);
|
|
143
|
+
await assertPortReusable(b.port);
|
|
144
|
+
for (const port of clientPorts) await assertPortReusable(port);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
switch (process.argv[2]) {
|
|
149
|
+
case "default-owned": {
|
|
150
|
+
const absentA = new NtpFetcher(config());
|
|
151
|
+
const absentB = new NtpFetcher(config());
|
|
152
|
+
const empty = new NtpFetcher(config([]));
|
|
153
|
+
assert.notEqual(absentA.ntpTimeSync, absentB.ntpTimeSync);
|
|
154
|
+
assert.notEqual(absentA.ntpTimeSync, empty.ntpTimeSync);
|
|
155
|
+
assert.notEqual(absentB.ntpTimeSync, empty.ntpTimeSync);
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
case "configured-copy": {
|
|
159
|
+
const servers = ["127.0.0.1:12345"];
|
|
160
|
+
const fetcher = new NtpFetcher(config(servers));
|
|
161
|
+
servers[0] = "127.0.0.1:54321";
|
|
162
|
+
servers.push("127.0.0.1:11111");
|
|
163
|
+
const options = (fetcher.ntpTimeSync as any).options;
|
|
164
|
+
assert.deepEqual(options.servers, [{ host: "127.0.0.1", port: 12345 }]);
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
case "sequential-isolation": {
|
|
168
|
+
await withTwoServers(async (a, b) => {
|
|
169
|
+
const fetcherA = new NtpFetcher(config([`127.0.0.1:${a.port}`]));
|
|
170
|
+
const fetcherB = new NtpFetcher(config([`127.0.0.1:${b.port}`]));
|
|
171
|
+
assert.notEqual(fetcherA.ntpTimeSync, fetcherB.ntpTimeSync);
|
|
172
|
+
await fetcherA.ntpTimeSync.getTime();
|
|
173
|
+
assert.equal(a.hits(), 8);
|
|
174
|
+
assert.equal(b.hits(), 0);
|
|
175
|
+
await fetcherB.ntpTimeSync.getTime();
|
|
176
|
+
assert.equal(a.hits(), 8);
|
|
177
|
+
assert.equal(b.hits(), 8);
|
|
178
|
+
await fetcherA.ntpTimeSync.getTime();
|
|
179
|
+
await fetcherB.ntpTimeSync.getTime();
|
|
180
|
+
assert.equal(a.hits(), 8);
|
|
181
|
+
assert.equal(b.hits(), 8);
|
|
182
|
+
});
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
case "concurrent-isolation": {
|
|
186
|
+
await withTwoServers(async (a, b) => {
|
|
187
|
+
const fetcherA = new NtpFetcher(config([`127.0.0.1:${a.port}`]));
|
|
188
|
+
const fetcherB = new NtpFetcher(config([`127.0.0.1:${b.port}`]));
|
|
189
|
+
assert.notEqual(fetcherA.ntpTimeSync, fetcherB.ntpTimeSync);
|
|
190
|
+
const [resultA, resultB] = await Promise.all([
|
|
191
|
+
fetcherA.ntpTimeSync.getTime(),
|
|
192
|
+
fetcherB.ntpTimeSync.getTime(),
|
|
193
|
+
]);
|
|
194
|
+
assert(resultA.offset > -1_000 && resultA.offset < 1_000);
|
|
195
|
+
assert(resultB.offset > 99_000 && resultB.offset < 101_000);
|
|
196
|
+
assert.equal(a.hits(), 8);
|
|
197
|
+
assert.equal(b.hits(), 8);
|
|
198
|
+
});
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
case "numeric-page-one": {
|
|
202
|
+
const fetcher = {
|
|
203
|
+
getLatestPage: function* () {
|
|
204
|
+
return 100;
|
|
205
|
+
},
|
|
206
|
+
intervalFromStart: (start: number) => ({ from: start, to: start + 9 }),
|
|
207
|
+
previousInterval: (start: number) => ({
|
|
208
|
+
from: start - 10,
|
|
209
|
+
to: start - 1,
|
|
210
|
+
}),
|
|
211
|
+
nextInterval: (end: number) => ({ from: end + 1, to: end + 10 }),
|
|
212
|
+
} as any;
|
|
213
|
+
const state = new NtpSyncState(
|
|
214
|
+
undefined as any,
|
|
215
|
+
config(),
|
|
216
|
+
fetcher,
|
|
217
|
+
undefined as any,
|
|
218
|
+
);
|
|
219
|
+
const input = await run(() => state.stateToInput());
|
|
220
|
+
assert.deepEqual(input, { from: 1, to: 10, isPresync: true });
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
case "offset-warnings": {
|
|
224
|
+
const fetcher = new NtpFetcher(config());
|
|
225
|
+
const originalConsoleError = console.error;
|
|
226
|
+
const warnings: string[] = [];
|
|
227
|
+
console.error = (...values: unknown[]) => warnings.push(values.join(" "));
|
|
228
|
+
try {
|
|
229
|
+
for (const offset of [6_001, -6_001, 5_000, -5_000]) {
|
|
230
|
+
(fetcher.ntpTimeSync as any).getTime = async () => ({
|
|
231
|
+
now: new Date(10_000),
|
|
232
|
+
offset,
|
|
233
|
+
});
|
|
234
|
+
await run(() => fetcher.getLatestPage(undefined));
|
|
235
|
+
}
|
|
236
|
+
} finally {
|
|
237
|
+
console.error = originalConsoleError;
|
|
238
|
+
}
|
|
239
|
+
assert.equal(warnings.length, 2);
|
|
240
|
+
assert(warnings[0].includes("6001"));
|
|
241
|
+
assert(warnings[1].includes("-6001"));
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
default:
|
|
245
|
+
assert.fail(`unknown NTP fetcher scenario: ${process.argv[2]}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
console.log(`ok ${process.argv[2]}`);
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
async function runCase(name: string): Promise<void> {
|
|
4
|
+
const child = Bun.spawn(
|
|
5
|
+
[process.execPath, "packages/node-sdk/sync/test/ntp-fetcher-case.ts", name],
|
|
6
|
+
{ cwd: process.cwd(), stdout: "pipe", stderr: "pipe", env: process.env },
|
|
7
|
+
);
|
|
8
|
+
let timedOut = false;
|
|
9
|
+
const timeout = setTimeout(() => {
|
|
10
|
+
timedOut = true;
|
|
11
|
+
child.kill();
|
|
12
|
+
}, 30_000);
|
|
13
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
14
|
+
child.exited,
|
|
15
|
+
new Response(child.stdout).text(),
|
|
16
|
+
new Response(child.stderr).text(),
|
|
17
|
+
]);
|
|
18
|
+
clearTimeout(timeout);
|
|
19
|
+
expect(timedOut, `${name} timed out`).toBe(false);
|
|
20
|
+
expect(exitCode, `${name}\n${stdout}\n${stderr}`).toBe(0);
|
|
21
|
+
expect(stdout).toContain(`ok ${name}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test(
|
|
25
|
+
"absent and empty fetchers each own a clock instance",
|
|
26
|
+
() => runCase("default-owned"),
|
|
27
|
+
35_000,
|
|
28
|
+
);
|
|
29
|
+
test(
|
|
30
|
+
"configured server arrays are defensively copied",
|
|
31
|
+
() => runCase("configured-copy"),
|
|
32
|
+
35_000,
|
|
33
|
+
);
|
|
34
|
+
test(
|
|
35
|
+
"sequential configured fetchers isolate traffic and cache",
|
|
36
|
+
() => runCase("sequential-isolation"),
|
|
37
|
+
35_000,
|
|
38
|
+
);
|
|
39
|
+
test(
|
|
40
|
+
"concurrent configured fetchers isolate traffic and signed offsets",
|
|
41
|
+
() => runCase("concurrent-isolation"),
|
|
42
|
+
35_000,
|
|
43
|
+
);
|
|
44
|
+
test(
|
|
45
|
+
"explicit numeric NTP state preserves historical page-1 presync",
|
|
46
|
+
() => runCase("numeric-page-one"),
|
|
47
|
+
35_000,
|
|
48
|
+
);
|
|
49
|
+
test(
|
|
50
|
+
"offset warnings use magnitude in both clock directions",
|
|
51
|
+
() => runCase("offset-warnings"),
|
|
52
|
+
35_000,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
test("NTP dependencies and lock integrity are exact", async () => {
|
|
56
|
+
const manifest = await Bun.file("packages/node-sdk/sync/package.json").json();
|
|
57
|
+
const lock = await Bun.file("bun.lock").text();
|
|
58
|
+
expect(manifest.dependencies["ntp-time-sync"]).toBe("0.6.0");
|
|
59
|
+
// ntp-packet-parser is ntp-time-sync's own dependency, not a direct one.
|
|
60
|
+
expect(manifest.dependencies["ntp-packet-parser"]).toBeUndefined();
|
|
61
|
+
expect(lock).toContain('"ntp-time-sync": ["ntp-time-sync@0.6.0"');
|
|
62
|
+
expect(lock).toContain(
|
|
63
|
+
"sha512-7hKnLUZN04goatNThks+raCAI4xRkF0UTdvoj7zpGsp1LDtyAJp4yguIIFYsLng6BKlheYfIOOyqY+eqvPsaCA==",
|
|
64
|
+
);
|
|
65
|
+
expect(lock).toContain('"ntp-packet-parser": ["ntp-packet-parser@0.6.1"');
|
|
66
|
+
expect(lock).toContain(
|
|
67
|
+
"sha512-nIDaxXcaxDsSj0Fh+Daa1nsmBwc2ENxeWOHmyEnbj7Cl3Jgzl03gCqV+jWOAvB46ooibbAaTAjMhy0lzlHUPSg==",
|
|
68
|
+
);
|
|
69
|
+
});
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { createSocket, type RemoteInfo, type Socket } from "node:dgram";
|
|
3
|
+
import { getNtpTip, NtpTipError } from "../src/sync-protocols/ntp/tip.ts";
|
|
4
|
+
|
|
5
|
+
const NTP_EPOCH_OFFSET_MS = 2_208_988_800_000;
|
|
6
|
+
const BLOCK_TIME_MS = 600_000;
|
|
7
|
+
|
|
8
|
+
type Responder = (request: Buffer, remote: RemoteInfo, socket: Socket) => void;
|
|
9
|
+
|
|
10
|
+
type Fixture = {
|
|
11
|
+
server: string;
|
|
12
|
+
hits: () => number;
|
|
13
|
+
close: () => Promise<void>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const fixtures: Fixture[] = [];
|
|
17
|
+
|
|
18
|
+
afterEach(async () => {
|
|
19
|
+
await Promise.all(fixtures.splice(0).map((fixture) => fixture.close()));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function writeTimestamp(buffer: Buffer, offset: number, unixMs: number): void {
|
|
23
|
+
const ntpMs = unixMs + NTP_EPOCH_OFFSET_MS;
|
|
24
|
+
buffer.writeUInt32BE(Math.floor(ntpMs / 1000) >>> 0, offset);
|
|
25
|
+
buffer.writeUInt32BE(Math.floor(((ntpMs % 1000) / 1000) * 2 ** 32) >>> 0, offset + 4);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A valid NTPv4 server response whose clock reads `serverTimeMs`. */
|
|
29
|
+
function responseFor(request: Buffer, serverTimeMs: number): Buffer {
|
|
30
|
+
const response = Buffer.alloc(48);
|
|
31
|
+
response[0] = (4 << 3) | 4; // leap 0, version 4, mode server
|
|
32
|
+
response[1] = 1; // stratum
|
|
33
|
+
response[3] = 0xec; // precision, 2^-20s
|
|
34
|
+
response.write("LOCL", 12, 4, "ascii");
|
|
35
|
+
writeTimestamp(response, 16, serverTimeMs - 1_000);
|
|
36
|
+
request.copy(response, 24, 40, 48); // echo the client transmit as origin
|
|
37
|
+
writeTimestamp(response, 32, serverTimeMs);
|
|
38
|
+
writeTimestamp(response, 40, serverTimeMs);
|
|
39
|
+
return response;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const serveTime =
|
|
43
|
+
(serverTimeMs: () => number): Responder =>
|
|
44
|
+
(request, remote, socket) =>
|
|
45
|
+
socket.send(responseFor(request, serverTimeMs()), remote.port, remote.address);
|
|
46
|
+
|
|
47
|
+
const silent: Responder = () => {};
|
|
48
|
+
|
|
49
|
+
const serveGarbage: Responder = (_request, remote, socket) =>
|
|
50
|
+
socket.send(Buffer.alloc(4), remote.port, remote.address);
|
|
51
|
+
|
|
52
|
+
async function startFixture(responder: Responder): Promise<Fixture> {
|
|
53
|
+
const socket = createSocket("udp4");
|
|
54
|
+
await new Promise<void>((resolve, reject) => {
|
|
55
|
+
socket.once("error", reject);
|
|
56
|
+
socket.bind(0, "127.0.0.1", () => {
|
|
57
|
+
socket.removeListener("error", reject);
|
|
58
|
+
resolve();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
let hits = 0;
|
|
62
|
+
socket.on("message", (request, remote) => {
|
|
63
|
+
hits += 1;
|
|
64
|
+
responder(request, remote, socket);
|
|
65
|
+
});
|
|
66
|
+
const fixture: Fixture = {
|
|
67
|
+
server: `127.0.0.1:${socket.address().port}`,
|
|
68
|
+
hits: () => hits,
|
|
69
|
+
close: () =>
|
|
70
|
+
new Promise<void>((resolve) => {
|
|
71
|
+
try {
|
|
72
|
+
socket.close(() => resolve());
|
|
73
|
+
} catch {
|
|
74
|
+
resolve();
|
|
75
|
+
}
|
|
76
|
+
}),
|
|
77
|
+
};
|
|
78
|
+
fixtures.push(fixture);
|
|
79
|
+
return fixture;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function expectTipError(
|
|
83
|
+
promise: Promise<unknown>,
|
|
84
|
+
code: NtpTipError["code"],
|
|
85
|
+
): Promise<NtpTipError> {
|
|
86
|
+
try {
|
|
87
|
+
await promise;
|
|
88
|
+
} catch (error) {
|
|
89
|
+
expect(error).toBeInstanceOf(NtpTipError);
|
|
90
|
+
expect((error as NtpTipError).code).toBe(code);
|
|
91
|
+
return error as NtpTipError;
|
|
92
|
+
}
|
|
93
|
+
throw new Error(`Expected NtpTipError(${code})`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
describe("getNtpTip validation", () => {
|
|
97
|
+
test.each([
|
|
98
|
+
["missing options", undefined],
|
|
99
|
+
["non-object options", 42],
|
|
100
|
+
["fractional startTime", { startTime: 0.5, blockTimeMS: 1000 }],
|
|
101
|
+
["unsafe startTime", { startTime: 2 ** 53, blockTimeMS: 1000 }],
|
|
102
|
+
["zero blockTimeMS", { startTime: 0, blockTimeMS: 0 }],
|
|
103
|
+
["negative blockTimeMS", { startTime: 0, blockTimeMS: -1 }],
|
|
104
|
+
["fractional blockTimeMS", { startTime: 0, blockTimeMS: 0.5 }],
|
|
105
|
+
["zero timeout", { startTime: 0, blockTimeMS: 1000, requestTimeoutMs: 0 }],
|
|
106
|
+
[
|
|
107
|
+
"timeout past the timer clamp",
|
|
108
|
+
{ startTime: 0, blockTimeMS: 1000, requestTimeoutMs: 2 ** 31 },
|
|
109
|
+
],
|
|
110
|
+
["non-array servers", { startTime: 0, blockTimeMS: 1000, servers: "x" }],
|
|
111
|
+
["empty server", { startTime: 0, blockTimeMS: 1000, servers: [""] }],
|
|
112
|
+
[
|
|
113
|
+
"padded server",
|
|
114
|
+
{ startTime: 0, blockTimeMS: 1000, servers: [" host "] },
|
|
115
|
+
],
|
|
116
|
+
["non-signal", { startTime: 0, blockTimeMS: 1000, signal: 7 }],
|
|
117
|
+
])("rejects %s", async (_name, options) => {
|
|
118
|
+
await expectTipError(
|
|
119
|
+
getNtpTip(options as never),
|
|
120
|
+
"INVALID_OPTIONS",
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("getNtpTip network behavior", () => {
|
|
126
|
+
test("returns the inclusive page for the sampled NTP time", async () => {
|
|
127
|
+
const startTime = Date.now();
|
|
128
|
+
const fixture = await startFixture(
|
|
129
|
+
serveTime(() => startTime + 3 * BLOCK_TIME_MS + BLOCK_TIME_MS / 2),
|
|
130
|
+
);
|
|
131
|
+
await expect(
|
|
132
|
+
getNtpTip({ startTime, blockTimeMS: BLOCK_TIME_MS, servers: [fixture.server] }),
|
|
133
|
+
).resolves.toEqual({ height: 3 });
|
|
134
|
+
expect(fixture.hits()).toBe(1);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("concurrent calls own their server configuration", async () => {
|
|
138
|
+
const startTime = Date.now();
|
|
139
|
+
const fixtureA = await startFixture(
|
|
140
|
+
serveTime(() => startTime + 3 * BLOCK_TIME_MS + BLOCK_TIME_MS / 2),
|
|
141
|
+
);
|
|
142
|
+
const fixtureB = await startFixture(
|
|
143
|
+
serveTime(() => startTime + 7 * BLOCK_TIME_MS + BLOCK_TIME_MS / 2),
|
|
144
|
+
);
|
|
145
|
+
const [a, b] = await Promise.all([
|
|
146
|
+
getNtpTip({ startTime, blockTimeMS: BLOCK_TIME_MS, servers: [fixtureA.server] }),
|
|
147
|
+
getNtpTip({ startTime, blockTimeMS: BLOCK_TIME_MS, servers: [fixtureB.server] }),
|
|
148
|
+
]);
|
|
149
|
+
expect(a).toEqual({ height: 3 });
|
|
150
|
+
expect(b).toEqual({ height: 7 });
|
|
151
|
+
expect(fixtureA.hits()).toBe(1);
|
|
152
|
+
expect(fixtureB.hits()).toBe(1);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("a sampled time before startTime is INVALID_TIME", async () => {
|
|
156
|
+
const startTime = Date.now();
|
|
157
|
+
const fixture = await startFixture(
|
|
158
|
+
serveTime(() => startTime - 10 * BLOCK_TIME_MS),
|
|
159
|
+
);
|
|
160
|
+
await expectTipError(
|
|
161
|
+
getNtpTip({ startTime, blockTimeMS: BLOCK_TIME_MS, servers: [fixture.server] }),
|
|
162
|
+
"INVALID_TIME",
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("a silent server is bounded by the deadline", async () => {
|
|
167
|
+
const fixture = await startFixture(silent);
|
|
168
|
+
const error = await expectTipError(
|
|
169
|
+
getNtpTip({
|
|
170
|
+
startTime: 0,
|
|
171
|
+
blockTimeMS: BLOCK_TIME_MS,
|
|
172
|
+
servers: [fixture.server],
|
|
173
|
+
requestTimeoutMs: 300,
|
|
174
|
+
}),
|
|
175
|
+
"TIMEOUT",
|
|
176
|
+
);
|
|
177
|
+
expect(error.timeoutMs).toBe(300);
|
|
178
|
+
expect(fixture.hits()).toBe(1);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("malformed responses fail as NETWORK with a cause", async () => {
|
|
182
|
+
const fixture = await startFixture(serveGarbage);
|
|
183
|
+
const error = await expectTipError(
|
|
184
|
+
getNtpTip({
|
|
185
|
+
startTime: 0,
|
|
186
|
+
blockTimeMS: BLOCK_TIME_MS,
|
|
187
|
+
servers: [fixture.server],
|
|
188
|
+
requestTimeoutMs: 5_000,
|
|
189
|
+
}),
|
|
190
|
+
"NETWORK",
|
|
191
|
+
);
|
|
192
|
+
expect(error.cause).toBeDefined();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("an already-aborted signal sends no packets", async () => {
|
|
196
|
+
const fixture = await startFixture(serveTime(() => Date.now()));
|
|
197
|
+
const controller = new AbortController();
|
|
198
|
+
const reason = new Error("stop before start");
|
|
199
|
+
controller.abort(reason);
|
|
200
|
+
const error = await expectTipError(
|
|
201
|
+
getNtpTip({
|
|
202
|
+
startTime: 0,
|
|
203
|
+
blockTimeMS: BLOCK_TIME_MS,
|
|
204
|
+
servers: [fixture.server],
|
|
205
|
+
signal: controller.signal,
|
|
206
|
+
}),
|
|
207
|
+
"ABORTED",
|
|
208
|
+
);
|
|
209
|
+
expect(error.cause).toBe(reason);
|
|
210
|
+
expect(fixture.hits()).toBe(0);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("an in-flight abort keeps its cause and removes its listener", async () => {
|
|
214
|
+
const fixture = await startFixture(silent);
|
|
215
|
+
const controller = new AbortController();
|
|
216
|
+
const reason = new Error("stop mid-flight");
|
|
217
|
+
const pending = getNtpTip({
|
|
218
|
+
startTime: 0,
|
|
219
|
+
blockTimeMS: BLOCK_TIME_MS,
|
|
220
|
+
servers: [fixture.server],
|
|
221
|
+
requestTimeoutMs: 1_000,
|
|
222
|
+
signal: controller.signal,
|
|
223
|
+
});
|
|
224
|
+
setTimeout(() => controller.abort(reason), 50);
|
|
225
|
+
const error = await expectTipError(pending, "ABORTED");
|
|
226
|
+
expect(error.cause).toBe(reason);
|
|
227
|
+
});
|
|
228
|
+
});
|