Improvements
This commit is contained in:
@@ -16,6 +16,7 @@ import { NETWORK, CONSENSUS, blockClaimMessage, nftTransferMessage,
|
||||
type MiningTemplate, type NftTransfer, type TestnetBlock, type TestnetSnapshot } from '../shared/testnet.ts';
|
||||
import { marketMessage, type MarketAction, type MarketInfo, type MarketListing, type MarketOrder,
|
||||
type MarketActivity } from '../shared/market.ts';
|
||||
import { StateClient } from '../shared/rpc-client.ts';
|
||||
|
||||
const root = fileURLToPath(new URL('../', import.meta.url));
|
||||
const wikipediaMock = resolve(root, 'tests', 'mock-wikipedia.mjs');
|
||||
@@ -203,6 +204,10 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
|
||||
await (await request(first.base, '/api/wikipedia/not-a-page', 400)).text();
|
||||
await (await request(first.base, '/api/wikipedia/9999999999', 503)).text();
|
||||
const initial = await snapshot(first.base);
|
||||
const pagedClient=new StateClient(((path: string,init?: RequestInit)=>fetch(new URL(path,first.base),init)) as typeof fetch);
|
||||
assert.deepEqual(await pagedClient.load(),initial);
|
||||
await (await request(first.base,'/api/canonical?tip=bad',400)).text();
|
||||
await (await request(first.base,`/api/canonical?tip=${GENESIS_HASH}&limit=17`,400)).text();
|
||||
assert.equal(initial.network, NETWORK);
|
||||
assert.equal(initial.chainId, CHAIN_ID);
|
||||
assert.equal(initial.genesis, GENESIS_HASH);
|
||||
@@ -358,6 +363,7 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
|
||||
}
|
||||
first = await start(firstDir); // Ledger.open revalidates every persisted block using native RandomX.
|
||||
const restored = await snapshot(first.base);
|
||||
assert.deepEqual(await pagedClient.load(),restored);
|
||||
const restoredMarket = await json<{ info: MarketInfo }>(first.base, '/api/market');
|
||||
assert.equal(restoredMarket.info.marketId, market.info.marketId);
|
||||
assert.equal((await marketCommand<MarketActivity>(first.base, market.info, bob, { type: 'activity' })).orders[0].id, order.id);
|
||||
|
||||
Executable
+73
@@ -0,0 +1,73 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import express from 'express';
|
||||
import { once } from 'node:events';
|
||||
import { RpcBudget, RpcError, resourceConfig } from '../server/resources.ts';
|
||||
|
||||
test('global admission has bounded concurrency, write tokens, refill and idempotent release', () => {
|
||||
let time = 0;
|
||||
const config = { ...resourceConfig({}), requestsPerSecond: 3, writesPerSecond: 1, active: 2 };
|
||||
const budget = new RpcBudget(config, () => time);
|
||||
const a = budget.acquire(true);
|
||||
assert.throws(() => budget.acquire(true), /admission/);
|
||||
const b = budget.acquire(false);
|
||||
assert.throws(() => budget.acquire(false), /admission/);
|
||||
a(); a(); b();
|
||||
assert.equal(budget.status().active, 0);
|
||||
budget.acquire(false)();
|
||||
assert.throws(() => budget.acquire(false), /admission/);
|
||||
time = 1000;
|
||||
budget.acquire(true)();
|
||||
});
|
||||
|
||||
test('response bytes are bounded globally and refill without per-client storage', () => {
|
||||
let time = 0;
|
||||
const budget = new RpcBudget({ ...resourceConfig({}), responseBytes: 50, bytesPerSecond: 60 }, () => time);
|
||||
assert.throws(() => budget.encode('x'.repeat(50)), /response limit/);
|
||||
budget.encode('x'.repeat(28)); budget.encode('x'.repeat(28));
|
||||
assert.throws(() => budget.encode('a'), /byte budget/);
|
||||
time = 1000; assert.equal(budget.encode('a'), '"a"');
|
||||
for (const value of ['-1','0','Infinity','1.2','']) assert.throws(() => resourceConfig({ WIKIDECK_RPC_MAX_ACTIVE: value }));
|
||||
});
|
||||
|
||||
test('isolated HTTP burst sheds excess work, preserves peer/health lanes and releases slots', async t => {
|
||||
const app = express(), cfg = { ...resourceConfig({}), active: 4, requestsPerSecond: 1000 };
|
||||
const publicBudget = new RpcBudget(cfg), peerBudget = new RpcBudget(cfg);
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
app.get('/healthz', (_req,res) => { res.json({ status:'ok' }); });
|
||||
app.use('/api',publicBudget.middleware()); app.use('/p2p',peerBudget.middleware());
|
||||
app.get('/api/hold',async (_req,res)=>{ await gate; res.json({ok:true}); });
|
||||
app.get('/p2p/status',(_req,res)=>{ res.json({ok:true}); });
|
||||
const server=app.listen(0,'127.0.0.1'); await once(server,'listening');
|
||||
const address=server.address(); assert.ok(address&&typeof address==='object');
|
||||
const base=`http://127.0.0.1:${address.port}`;
|
||||
t.after(()=>{release();server.closeAllConnections();server.close();});
|
||||
const requests=Array.from({length:40},()=>fetch(`${base}/api/hold`,{signal:AbortSignal.timeout(5000)}));
|
||||
// The first rejected response proves requests reached admission while accepted requests remain held.
|
||||
const first=await Promise.race(requests); assert.equal(first.status,503);
|
||||
assert.equal((await fetch(`${base}/p2p/status`)).status,200);
|
||||
assert.equal((await fetch(`${base}/healthz`)).status,200);
|
||||
release();
|
||||
const responses=await Promise.all(requests);
|
||||
const counts=responses.reduce((out,r)=>{out[r.status]=(out[r.status]??0)+1;return out;},{} as Record<number,number>);
|
||||
assert.equal(counts[200],4); assert.equal(counts[503],36);
|
||||
for(const r of responses)await r.text();
|
||||
assert.equal(publicBudget.status().active,0);
|
||||
t.diagnostic(JSON.stringify({requests:40,accepted:counts[200],shed:counts[503],peerLane:'available',health:'available'}));
|
||||
});
|
||||
|
||||
test('oversized response errors restore JSON handling and do not recurse', async t => {
|
||||
const app=express(),budget=new RpcBudget({...resourceConfig({}),responseBytes:128});
|
||||
app.use(budget.middleware());
|
||||
app.get('/large',(_req,res)=>{res.json({data:'x'.repeat(1000)});});
|
||||
app.use((error:Error,_req:express.Request,res:express.Response,_next:express.NextFunction)=>{
|
||||
res.status(error instanceof RpcError?error.status:500).json({error:error.message});
|
||||
});
|
||||
const server=app.listen(0,'127.0.0.1');await once(server,'listening');
|
||||
t.after(()=>{server.closeAllConnections();server.close();});
|
||||
const a=server.address();assert.ok(a&&typeof a==='object');
|
||||
const r=await fetch(`http://127.0.0.1:${a.port}/large`,{signal:AbortSignal.timeout(5000)});
|
||||
assert.equal(r.status,413);assert.match((await r.json()).error,/response limit/);
|
||||
assert.equal(budget.status().active,0);
|
||||
});
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { StateClient } from '../shared/rpc-client.ts';
|
||||
import { NETWORK, type TestnetBlock } from '../shared/testnet.ts';
|
||||
|
||||
const genesis='0'.repeat(64),chainId='f'.repeat(64);
|
||||
const block=(height:number,previousHash:string):TestnetBlock=>({ network:NETWORK,chainId,height,
|
||||
hash:height.toString(16).padStart(64,'0'),previousHash,transactions:[] }) as unknown as TestnetBlock;
|
||||
function fixture(count:number){
|
||||
const blocks:TestnetBlock[]=[];for(let i=0;i<count;i++)blocks.push(block(i+1,blocks.at(-1)?.hash??genesis));
|
||||
const calls:string[]=[];
|
||||
let malformed=false,conflict=false;
|
||||
const fetcher=(async(path: string)=>{
|
||||
calls.push(path); const url=new URL(path,'http://localhost');
|
||||
const tip=blocks.at(-1)?.hash??genesis;
|
||||
if(url.pathname==='/api/summary')return Response.json({network:NETWORK,chainId,genesis,tip,height:blocks.length,
|
||||
cumulativeWork:String(blocks.length),pendingTransfers:[],peers:0,algorithm:'RandomX',maturity:2,revision:0});
|
||||
if(conflict){conflict=false;return Response.json({error:'changed'},{status:409});}
|
||||
const offset=Number(url.searchParams.get('offset')),limit=Number(url.searchParams.get('limit'));
|
||||
const page=blocks.slice(offset,offset+limit);
|
||||
return Response.json({network:NETWORK,chainId,tip,height:blocks.length,offset,blocks:page,
|
||||
next:malformed?offset:(offset+page.length<blocks.length?offset+page.length:null)});
|
||||
}) as typeof fetch;
|
||||
return {blocks,calls,client:new StateClient(fetcher),malform:()=>{malformed=true;},conflict:()=>{conflict=true;}};
|
||||
}
|
||||
test('paged state loads contiguous history and reuses cache when unchanged or appended',async()=>{
|
||||
const f=fixture(35);const initial=await f.client.load();assert.equal(initial.blocks.length,35);
|
||||
assert.equal(f.calls.filter(p=>p.includes('canonical')).length,3);
|
||||
f.calls.length=0;await f.client.load();assert.equal(f.calls.length,2);
|
||||
f.blocks.push(block(36,f.blocks.at(-1)!.hash));f.calls.length=0;
|
||||
assert.equal((await f.client.load()).blocks.length,36);
|
||||
assert.equal(f.calls.filter(p=>p.includes('canonical')).length,2); // prefix check + append
|
||||
});
|
||||
test('paged state rejects malformed pagination, continuity and excessive history',async()=>{
|
||||
const bad=fixture(17);bad.malform();await assert.rejects(bad.client.load(),/pagination/);
|
||||
const broken=fixture(3);broken.blocks[1].previousHash='a'.repeat(64);await assert.rejects(broken.client.load(),/continuity/);
|
||||
const big=fixture(10001);await assert.rejects(big.client.load(),/history limit/);
|
||||
});
|
||||
test('tip conflict restarts once; parallel refreshes share bounded work',async()=>{
|
||||
const f=fixture(20);f.conflict();
|
||||
const [a,b]=await Promise.all([f.client.load(),f.client.load()]);
|
||||
assert.equal(a,b);assert.equal(a.blocks.length,20);
|
||||
assert.equal(f.calls.filter(p=>p==='/api/summary').length,3);
|
||||
});
|
||||
test('oversized node responses are rejected before JSON parsing',async()=>{
|
||||
const client=new StateClient((async()=>new Response('{}',{headers:{'Content-Type':'application/json','Content-Length':'99999999'}})) as typeof fetch);
|
||||
await assert.rejects(client.load(),/page limit/);
|
||||
});
|
||||
@@ -124,6 +124,16 @@ test('testnet genesis is empty, manifest-bound, isolated and defensively copied'
|
||||
copy.article.title = 'getter mutation';
|
||||
assert.notEqual(ledger.block(candidate.hash)!.article.title, copy.article.title);
|
||||
assert.equal(ledger.size, 1);
|
||||
const summary=ledger.summary();
|
||||
assert.equal(summary.height,1);assert.equal(summary.tip,candidate.hash);
|
||||
const page=ledger.canonicalPage(summary.tip,0,1);
|
||||
assert.equal(page.blocks.length,1);assert.equal(page.next,null);
|
||||
page.blocks[0].article.title='mutated page';
|
||||
assert.notEqual(ledger.canonicalPage(summary.tip,0).blocks[0].article.title,'mutated page');
|
||||
assert.throws(()=>ledger.canonicalPage(GENESIS_HASH,0),/tip changed/);
|
||||
assert.throws(()=>ledger.canonicalPage(summary.tip,0,17),/Invalid/);
|
||||
assert.throws(()=>ledger.legacySnapshot(0),/Full snapshot disabled/);
|
||||
assert.equal(ledger.legacySnapshot(1).blocks.length,1);
|
||||
assert.throws(() => ledger.inventory(-1, 32));
|
||||
assert.throws(() => ledger.inventory(0, 0));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user