diff --git a/docs/docs/guides/05-running-tests.md b/docs/docs/guides/05-running-tests.md index 420f8f5..d776383 100644 --- a/docs/docs/guides/05-running-tests.md +++ b/docs/docs/guides/05-running-tests.md @@ -26,6 +26,12 @@ For example: POCKET_IC_LOG_DIR=./logs POCKET_IC_LOG_DIR_LEVELS=trace npm test ``` +It's also possible to set individual log levels for different modules, for examples: + +```shell +POCKET_IC_LOG_DIR=./logs POCKET_IC_LOG_DIR_LEVELS=pocket_ic_server=trace,tower_http=info,axum::rejection=info npm test +``` + ### Runtime logs Logs for the IC runtime can be configured when running the PocketIC server using the `showRuntimeLogs` option, for example: diff --git a/docs/docs/guides/06-working-with-the-nns.md b/docs/docs/guides/06-working-with-the-nns.md index 1f80768..aa2772d 100644 --- a/docs/docs/guides/06-working-with-the-nns.md +++ b/docs/docs/guides/06-working-with-the-nns.md @@ -208,8 +208,11 @@ Now you can setup your PocketIC instance to use the NNS state: ```ts const pic = await PocketIc.create({ nns: { - fromPath: NNS_STATE_PATH, - subnetId: Principal.fromText(NNS_SUBNET_ID), + state: { + type: SubnetStateType.FromPath, + path: NNS_STATE_PATH, + subnetId: Principal.fromText(NNS_SUBNET_ID), + }, }, }); ``` diff --git a/examples/multicanister/tests/src/multicanister.spec.ts b/examples/multicanister/tests/src/multicanister.spec.ts index 1385485..f2240b5 100644 --- a/examples/multicanister/tests/src/multicanister.spec.ts +++ b/examples/multicanister/tests/src/multicanister.spec.ts @@ -1,5 +1,5 @@ import { resolve } from 'path'; -import { Actor, PocketIc } from '@hadronous/pic'; +import { Actor, PocketIc, SubnetStateType } from '@hadronous/pic'; import { IDL } from '@dfinity/candid'; import { @@ -54,7 +54,12 @@ describe('Multicanister', () => { let actor: Actor<_SERVICE>; beforeEach(async () => { - pic = await PocketIc.create(process.env.PIC_URL, { application: 2 }); + pic = await PocketIc.create(process.env.PIC_URL, { + application: [ + { state: { type: SubnetStateType.New } }, + { state: { type: SubnetStateType.New } }, + ], + }); const applicationSubnets = pic.getApplicationSubnets(); const mainSubnet = applicationSubnets[0]; diff --git a/examples/nns_proxy/tests/src/nns-proxy.spec.ts b/examples/nns_proxy/tests/src/nns-proxy.spec.ts index 6d68abb..f91ec3b 100644 --- a/examples/nns_proxy/tests/src/nns-proxy.spec.ts +++ b/examples/nns_proxy/tests/src/nns-proxy.spec.ts @@ -1,6 +1,11 @@ import { resolve } from 'path'; import { Principal } from '@dfinity/principal'; -import { Actor, PocketIc, generateRandomIdentity } from '@hadronous/pic'; +import { + Actor, + PocketIc, + SubnetStateType, + generateRandomIdentity, +} from '@hadronous/pic'; import { _SERVICE, idlFactory } from '../../declarations/nns_proxy.did'; import { Governance } from './support/governance'; @@ -40,8 +45,11 @@ describe('NNS Proxy', () => { beforeEach(async () => { pic = await PocketIc.create(process.env.PIC_URL, { nns: { - fromPath: NNS_STATE_PATH, - subnetId: Principal.fromText(NNS_SUBNET_ID), + state: { + type: SubnetStateType.FromPath, + path: NNS_STATE_PATH, + subnetId: Principal.fromText(NNS_SUBNET_ID), + }, }, }); await pic.setTime(new Date(2024, 1, 30).getTime()); diff --git a/packages/pic/postinstall.mjs b/packages/pic/postinstall.mjs index f1d5fba..818a95a 100644 --- a/packages/pic/postinstall.mjs +++ b/packages/pic/postinstall.mjs @@ -9,7 +9,7 @@ const __dirname = dirname(__filename); const IS_LINUX = process.platform === 'linux'; const PLATFORM = IS_LINUX ? 'x86_64-linux' : 'x86_64-darwin'; -const VERSION = '3.0.1'; +const VERSION = '4.0.0'; const DOWNLOAD_PATH = `https://github.com/dfinity/pocketic/releases/download/${VERSION}/pocket-ic-${PLATFORM}.gz`; const TARGET_PATH = resolve(__dirname, 'pocket-ic'); diff --git a/packages/pic/src/error.ts b/packages/pic/src/error.ts index 6f537b9..a023e28 100644 --- a/packages/pic/src/error.ts +++ b/packages/pic/src/error.ts @@ -39,6 +39,12 @@ export class BinTimeoutError extends Error { } } +export class ServerRequestTimeoutError extends Error { + constructor() { + super('A request to the PocketIC server timed out.'); + } +} + export class InstanceDeletedError extends Error { constructor() { super( diff --git a/packages/pic/src/http2-client.ts b/packages/pic/src/http2-client.ts index 87657af..257aaf7 100644 --- a/packages/pic/src/http2-client.ts +++ b/packages/pic/src/http2-client.ts @@ -1,180 +1,205 @@ -import http2, { - ClientHttp2Session, - IncomingHttpHeaders, - OutgoingHttpHeaders, -} from 'node:http2'; +import { ServerRequestTimeoutError } from './error'; +import { isNil, poll } from './util'; -const { HTTP2_HEADER_PATH, HTTP2_HEADER_METHOD } = http2.constants; - -export interface Request { +export interface RequestOptions { method: Method; path: string; - headers?: OutgoingHttpHeaders; + headers?: RequestHeaders; body?: Uint8Array; } +export type RequestHeaders = RequestInit['headers']; + export interface JsonGetRequest { path: string; - headers?: OutgoingHttpHeaders; + headers?: RequestHeaders; } export interface JsonPostRequest { path: string; - headers?: OutgoingHttpHeaders; + headers?: RequestHeaders; body?: B; } -export interface Response { - status: number | undefined; - body: string; - headers: IncomingHttpHeaders; -} +export type ResponseHeaders = ResponseInit['headers']; export type Method = 'GET' | 'POST' | 'PUT' | 'DELETE'; -export const JSON_HEADER: OutgoingHttpHeaders = { +export const JSON_HEADER: RequestHeaders = { 'Content-Type': 'application/json', }; export class Http2Client { - private readonly session: ClientHttp2Session; - - constructor(baseUrl: string) { - this.session = http2.connect(baseUrl); - } - - public request(init: Request): Promise { - return new Promise((resolve, reject) => { - let req = this.session.request({ - [HTTP2_HEADER_PATH]: init.path, - [HTTP2_HEADER_METHOD]: init.method, - 'content-length': init.body?.length ?? 0, - ...init.headers, - }); - - req.on('error', error => { - console.error('Erorr sending request to PocketIC server', error); - return reject(error); - }); - - req.on('response', headers => { - const status = headers[':status'] ?? -1; - - const contentLength = headers['content-length'] - ? Number(headers['content-length']) - : 0; - let buffer = Buffer.alloc(contentLength); - let bufferLength = 0; - - req.on('data', (chunk: Buffer) => { - chunk.copy(buffer, bufferLength); - bufferLength += chunk.length; + constructor( + private readonly baseUrl: string, + private readonly processingTimeoutMs: number, + ) {} + + public request(init: RequestOptions): Promise { + const timeoutAbortController = new AbortController(); + const requestAbortController = new AbortController(); + + const cancelAfterTimeout = async (): Promise => { + return await new Promise((_, reject) => { + const timeoutId = setTimeout(() => { + requestAbortController.abort(); + reject(new ServerRequestTimeoutError()); + }, this.processingTimeoutMs); + + timeoutAbortController.signal.addEventListener('abort', () => { + clearTimeout(timeoutId); + reject(new ServerRequestTimeoutError()); }); + }); + }; - req.on('end', () => { - const body = buffer.toString('utf8'); + const makeRequest = async (): Promise => { + const url = `${this.baseUrl}${init.path}`; - return resolve({ - status, - body, - headers, - }); - }); + const res = await fetch(url, { + method: init.method, + headers: init.headers, + body: init.body, + signal: requestAbortController.signal, }); + timeoutAbortController.abort(); - if (init.body) { - req.write(init.body, 'utf8'); - } + return res; + }; - req.end(); - }); + return Promise.race([makeRequest(), cancelAfterTimeout()]); } public async jsonGet(init: JsonGetRequest): Promise { - while (true) { - const res = await this.request({ - method: 'GET', - path: init.path, - headers: { ...init.headers, ...JSON_HEADER }, - }); - - const resBody = JSON.parse(res.body) as ApiResponse; - if (!resBody) { - return resBody; - } - - // server encountered an error - if ('message' in resBody) { - console.error('PocketIC server encountered an error', resBody.message); + // poll the request until it is successful or times out + return await poll( + async () => { + const res = await this.request({ + method: 'GET', + path: init.path, + headers: { ...init.headers, ...JSON_HEADER }, + }); - throw new Error(resBody.message); - } + const resBody = (await res.json()) as ApiResponse; + if (!resBody) { + return resBody; + } - // the server has started processing or is busy - if ('state_label' in resBody) { - console.error('PocketIC server is too busy to process the request'); + // server encountered an error, throw and try again + if ('message' in resBody) { + console.error( + 'PocketIC server encountered an error', + resBody.message, + ); - if (res.status === 202) { - throw new Error('Server started processing'); + throw new Error(resBody.message); } - if (res.status === 409) { - throw new Error('Server busy'); + // the server has started processing or is busy + if ('state_label' in resBody) { + // the server is too busy to process the request, throw and try again + if (res.status === 409) { + throw new Error('Server busy'); + } + + // the server has started processing the request + // this shouldn't happen for GET requests, throw and try again + if (res.status === 202) { + throw new Error('Server started processing'); + } + + // something weird happened, throw and try again + throw new Error('Unknown state'); } - throw new Error('Unknown state'); - } - - return resBody; - } + // the request was successful, exit the loop + return resBody; + }, + { intervalMs: POLLING_INTERVAL_MS, timeoutMs: this.processingTimeoutMs }, + ); } - public async jsonPost(init: JsonPostRequest): Promise { + public async jsonPost(init: JsonPostRequest): Promise { const reqBody = init.body ? new TextEncoder().encode(JSON.stringify(init.body)) : undefined; - while (true) { - const res = await this.request({ - method: 'POST', - path: init.path, - headers: { ...init.headers, ...JSON_HEADER }, - body: reqBody, - }); - - const resBody = JSON.parse(res.body); - if (!resBody) { - return resBody; - } - - // server encountered an error - if ('message' in resBody) { - console.error('PocketIC server encountered an error', resBody.message); + // poll the request until it is successful or times out + return await poll( + async () => { + const res = await this.request({ + method: 'POST', + path: init.path, + headers: { ...init.headers, ...JSON_HEADER }, + body: reqBody, + }); - throw new Error(resBody.message); - } + const resBody = (await res.json()) as ApiResponse; + if (isNil(resBody)) { + return resBody; + } - // the server has started processing or is busy - // sleep and try again - if ('state_label' in resBody) { - console.error('PocketIC server is too busy to process the request'); + // server encountered an error, throw and try again + if ('message' in resBody) { + console.error( + 'PocketIC server encountered an error', + resBody.message, + ); - if (res.status === 202) { - throw new Error('Server started processing'); + throw new Error(resBody.message); } - if (res.status === 409) { - throw new Error('Server busy'); + // the server has started processing or is busy + if ('state_label' in resBody) { + // the server is too busy to process the request, throw and try again + if (res.status === 409) { + throw new Error('Server busy'); + } + + // the server has started processing the request, poll until it is done + if (res.status === 202) { + return await poll( + async () => { + const stateRes = await this.request({ + method: 'GET', + path: `/read_graph/${resBody.state_label}/${resBody.op_id}`, + }); + + const stateBody = (await stateRes.json()) as ApiResponse; + + // the server encountered an error, throw and try again + if ( + isNil(stateBody) || + 'message' in stateBody || + 'state_label' in stateBody + ) { + throw new Error('Polling has not succeeded yet'); + } + + // the request was successful, exit the loop + return stateBody; + }, + { + intervalMs: POLLING_INTERVAL_MS, + timeoutMs: this.processingTimeoutMs, + }, + ); + } + + // something weird happened, throw and try again + throw new Error('Unknown state'); } - throw new Error('Unknown state'); - } - - return resBody; - } + // the request was successful, exit the loop + return resBody; + }, + { intervalMs: POLLING_INTERVAL_MS, timeoutMs: this.processingTimeoutMs }, + ); } } +const POLLING_INTERVAL_MS = 10; + interface StartedOrBusyApiResponse { state_label: string; op_id: string; diff --git a/packages/pic/src/index.ts b/packages/pic/src/index.ts index 0d6ecd0..c662d51 100644 --- a/packages/pic/src/index.ts +++ b/packages/pic/src/index.ts @@ -1,14 +1,6 @@ -export { createIdentity, generateRandomIdentity } from './identity'; -export type { Actor, ActorInterface, ActorMethod } from './pocket-ic-actor'; -export { PocketIc } from './pocket-ic'; -export type { - CanisterFixture, - CreateCanisterOptions, - CreateInstanceOptions, - InstallCodeOptions, - ReinstallCodeOptions, - SetupCanisterOptions, - UpgradeCanisterOptions, -} from './pocket-ic-types'; -export { PocketIcServer } from './pocket-ic-server'; -export { StartServerOptions as ServerStartOptions } from './pocket-ic-server-types'; +export * from './identity'; +export * from './pocket-ic-actor'; +export * from './pocket-ic'; +export * from './pocket-ic-types'; +export * from './pocket-ic-server'; +export * from './pocket-ic-server-types'; diff --git a/packages/pic/src/pocket-ic-client-types.ts b/packages/pic/src/pocket-ic-client-types.ts index b3d87b4..db5d467 100644 --- a/packages/pic/src/pocket-ic-client-types.ts +++ b/packages/pic/src/pocket-ic-client-types.ts @@ -6,92 +6,175 @@ import { base64EncodePrincipal, hexDecode, isNil, + isNotNil, } from './util'; import { TopologyValidationError } from './error'; +//#region CreateInstance + export interface CreateInstanceRequest { - nns?: - | boolean - | { - fromPath: string; - subnetId: Principal; - }; - sns?: boolean; - ii?: boolean; - fiduciary?: boolean; - bitcoin?: boolean; - system?: number; - application?: number; + nns?: NnsSubnetConfig; + sns?: SnsSubnetConfig; + ii?: IiSubnetConfig; + fiduciary?: FiduciarySubnetConfig; + bitcoin?: BitcoinSubnetConfig; + system?: SystemSubnetConfig[]; + application?: ApplicationSubnetConfig[]; processingTimeoutMs?: number; } +export interface SubnetConfig< + T extends NewSubnetStateConfig | FromPathSubnetStateConfig = + | NewSubnetStateConfig + | FromPathSubnetStateConfig, +> { + enableDeterministicTimeSlicing?: boolean; + enableBenchmarkingInstructionLimits?: boolean; + state: T; +} + +export type NnsSubnetConfig = SubnetConfig; +export type NnsSubnetStateConfig = + | NewSubnetStateConfig + | FromPathSubnetStateConfig; + +export type SnsSubnetConfig = SubnetConfig; +export type SnsSubnetStateConfig = NewSubnetStateConfig; + +export type IiSubnetConfig = SubnetConfig; +export type IiSubnetStateConfig = NewSubnetStateConfig; + +export type FiduciarySubnetConfig = SubnetConfig; +export type FiduciarySubnetStateConfig = NewSubnetStateConfig; + +export type BitcoinSubnetConfig = SubnetConfig; +export type BitcoinSubnetStateConfig = NewSubnetStateConfig; + +export type SystemSubnetConfig = SubnetConfig; +export type SystemSubnetStateConfig = NewSubnetStateConfig; + +export type ApplicationSubnetConfig = + SubnetConfig; +export type ApplicationSubnetStateConfig = NewSubnetStateConfig; + +export interface NewSubnetStateConfig { + type: SubnetStateType.New; +} + +export interface FromPathSubnetStateConfig { + type: SubnetStateType.FromPath; + path: string; + subnetId: Principal; +} + +export enum SubnetStateType { + New = 'new', + FromPath = 'fromPath', +} + export interface EncodedCreateInstanceRequest { - nns?: - | 'New' - | { - FromPath: [ - string, - { - subnet_id: string; - }, - ]; - }; - sns?: string; - ii?: string; - fiduciary?: string; - bitcoin?: string; - system: string[]; - application: string[]; -} - -function encodeNnsConfig( - nns: CreateInstanceRequest['nns'], -): EncodedCreateInstanceRequest['nns'] { - if (isNil(nns)) { - return undefined; - } + nns?: EncodedSubnetConfig; + sns?: EncodedSubnetConfig; + ii?: EncodedSubnetConfig; + fiduciary?: EncodedSubnetConfig; + bitcoin?: EncodedSubnetConfig; + system: EncodedSubnetConfig[]; + application: EncodedSubnetConfig[]; +} - if (nns === true) { - return 'New'; - } +export interface EncodedSubnetConfig { + dts_flag: 'Enabled' | 'Disabled'; + instruction_config: 'Production' | 'Benchmarking'; + state_config: 'New' | { FromPath: [string, { subnet_id: string }] }; +} - if (nns === false) { +function encodeManySubnetConfigs( + configs: T[] = [], +): EncodedSubnetConfig[] { + return configs.map(encodeSubnetConfig).filter(isNotNil); +} + +function encodeSubnetConfig( + config?: T, +): EncodedSubnetConfig | undefined { + if (isNil(config)) { return undefined; } - if ('fromPath' in nns) { - return { - FromPath: [ - nns.fromPath, - { subnet_id: base64EncodePrincipal(nns.subnetId) }, - ], - }; + switch (config.state.type) { + default: { + return undefined; + } + + case SubnetStateType.New: { + return { + dts_flag: encodeDtsFlag(config.enableDeterministicTimeSlicing), + instruction_config: encodeInstructionConfig( + config.enableBenchmarkingInstructionLimits, + ), + state_config: 'New', + }; + } + + case SubnetStateType.FromPath: { + return { + dts_flag: encodeDtsFlag(config.enableDeterministicTimeSlicing), + instruction_config: encodeInstructionConfig( + config.enableBenchmarkingInstructionLimits, + ), + state_config: { + FromPath: [ + config.state.path, + { subnet_id: base64EncodePrincipal(config.state.subnetId) }, + ], + }, + }; + } } +} - return undefined; +function encodeDtsFlag( + enableDeterministicTimeSlicing?: boolean, +): EncodedSubnetConfig['dts_flag'] { + return enableDeterministicTimeSlicing === false ? 'Disabled' : 'Enabled'; +} + +function encodeInstructionConfig( + enableBenchmarkingInstructionLimits?: boolean, +): EncodedSubnetConfig['instruction_config'] { + return enableBenchmarkingInstructionLimits === true + ? 'Benchmarking' + : 'Production'; } export function encodeCreateInstanceRequest( req?: CreateInstanceRequest, ): EncodedCreateInstanceRequest { - const defaultOptions = req ?? { application: 1 }; + const defaultApplicationSubnet: ApplicationSubnetConfig = { + state: { type: SubnetStateType.New }, + }; + const defaultOptions: CreateInstanceRequest = req ?? { + application: [defaultApplicationSubnet], + }; const options: EncodedCreateInstanceRequest = { - nns: encodeNnsConfig(defaultOptions.nns), - sns: defaultOptions.sns ? 'New' : undefined, - ii: defaultOptions.ii ? 'New' : undefined, - fiduciary: defaultOptions.fiduciary ? 'New' : undefined, - bitcoin: defaultOptions.bitcoin ? 'New' : undefined, - system: new Array(defaultOptions.system ?? 0).fill('New'), - application: new Array(defaultOptions.application ?? 1).fill('New'), + nns: encodeSubnetConfig(defaultOptions.nns), + sns: encodeSubnetConfig(defaultOptions.sns), + ii: encodeSubnetConfig(defaultOptions.ii), + fiduciary: encodeSubnetConfig(defaultOptions.fiduciary), + bitcoin: encodeSubnetConfig(defaultOptions.bitcoin), + system: encodeManySubnetConfigs(defaultOptions.system), + application: encodeManySubnetConfigs( + defaultOptions.application ?? [defaultApplicationSubnet], + ), }; if ( - (options.nns !== 'New' && - options.sns !== 'New' && - options.ii !== 'New' && - options.fiduciary !== 'New' && - options.bitcoin !== 'New' && + (isNil(options.nns) && + isNil(options.sns) && + isNil(options.ii) && + isNil(options.fiduciary) && + isNil(options.bitcoin) && options.system.length === 0 && options.application.length === 0) || options.system.length < 0 || @@ -103,6 +186,8 @@ export function encodeCreateInstanceRequest( return options; } +//#endregion CreateInstance + //#region GetPubKey export interface GetPubKeyRequest { @@ -297,18 +382,24 @@ export type GetSubnetIdResponse = { subnetId: Principal | null; }; -export type EncodedGetSubnetIdResponse = { - subnet_id: string; -} | null; +export type EncodedGetSubnetIdResponse = + | { + subnet_id: string; + } + | {}; export function decodeGetSubnetIdResponse( res: EncodedGetSubnetIdResponse, ): GetSubnetIdResponse { - const subnetId = isNil(res?.subnet_id) - ? null - : base64DecodePrincipal(res.subnet_id); + if (isNil(res)) { + return { subnetId: null }; + } + + if ('subnet_id' in res) { + return { subnetId: base64DecodePrincipal(res.subnet_id) }; + } - return { subnetId }; + return { subnetId: null }; } //#endregion GetCanisterSubnetId diff --git a/packages/pic/src/pocket-ic-client.ts b/packages/pic/src/pocket-ic-client.ts index ea61ab9..8544cd4 100644 --- a/packages/pic/src/pocket-ic-client.ts +++ b/packages/pic/src/pocket-ic-client.ts @@ -1,4 +1,3 @@ -import { IncomingHttpHeaders } from 'http2'; import { Http2Client } from './http2-client'; import { EncodedAddCyclesRequest, @@ -55,29 +54,24 @@ import { decodeCanisterCallResponse, } from './pocket-ic-client-types'; -const PROCESSING_TIME_HEADER = 'processing-timeout-ms'; -const PROCESSING_TIME_VALUE_MS = 300_000; +const PROCESSING_TIME_VALUE_MS = 10_000; export class PocketIcClient { private isInstanceDeleted = false; - private readonly processingHeader: IncomingHttpHeaders; private constructor( private readonly serverClient: Http2Client, private readonly instancePath: string, private readonly topology: InstanceTopology, - processingTimeoutMs: number, - ) { - this.processingHeader = { - [PROCESSING_TIME_HEADER]: processingTimeoutMs.toString(), - }; - } + ) {} public static async create( url: string, req?: CreateInstanceRequest, ): Promise { - const serverClient = new Http2Client(url); + const processingTimeoutMs = + req?.processingTimeoutMs ?? PROCESSING_TIME_VALUE_MS; + const serverClient = new Http2Client(url, processingTimeoutMs); const res = await serverClient.jsonPost< EncodedCreateInstanceRequest, @@ -100,7 +94,6 @@ export class PocketIcClient { serverClient, `/instances/${instanceId}`, topology, - req?.processingTimeoutMs ?? PROCESSING_TIME_VALUE_MS, ); } @@ -115,10 +108,10 @@ export class PocketIcClient { this.isInstanceDeleted = true; } - public async tick(): Promise { + public async tick(): Promise<{}> { this.assertInstanceNotDeleted(); - return await this.post('/update/tick'); + return await this.post('/update/tick'); } public async getPubKey(req: GetPubKeyRequest): Promise { @@ -145,7 +138,7 @@ export class PocketIcClient { public async setTime(req: SetTimeRequest): Promise { this.assertInstanceNotDeleted(); - await this.post( + await this.post( '/update/set_time', encodeSetTimeRequest(req), ); @@ -197,15 +190,15 @@ export class PocketIcClient { body: encodeUploadBlobRequest(req), }); - return decodeUploadBlobResponse(res.body); + const body = await res.text(); + return decodeUploadBlobResponse(body); } public async setStableMemory(req: SetStableMemoryRequest): Promise { this.assertInstanceNotDeleted(); - await this.serverClient.jsonPost({ + await this.serverClient.jsonPost({ path: `${this.instancePath}/update/set_stable_memory`, - headers: this.processingHeader, body: encodeSetStableMemoryRequest(req), }); } @@ -251,10 +244,9 @@ export class PocketIcClient { return decodeCanisterCallResponse(res); } - private async post(endpoint: string, body?: B): Promise { + private async post(endpoint: string, body?: B): Promise { return await this.serverClient.jsonPost({ path: `${this.instancePath}${endpoint}`, - headers: this.processingHeader, body, }); } @@ -262,7 +254,6 @@ export class PocketIcClient { private async get(endpoint: string): Promise { return await this.serverClient.jsonGet({ path: `${this.instancePath}${endpoint}`, - headers: this.processingHeader, }); } diff --git a/packages/pic/src/pocket-ic-server.ts b/packages/pic/src/pocket-ic-server.ts index af79b99..f06bc3f 100644 --- a/packages/pic/src/pocket-ic-server.ts +++ b/packages/pic/src/pocket-ic-server.ts @@ -11,9 +11,9 @@ import { exists, readFileAsString, tmpFile, - poll, isArm, isDarwin, + poll, } from './util'; import { StartServerOptions } from './pocket-ic-server-types'; import { Writable } from 'node:stream'; @@ -69,9 +69,13 @@ export class PocketIcServer { const pid = process.ppid; const picFilePrefix = `pocket_ic_${pid}`; const portFilePath = tmpFile(`${picFilePrefix}.port`); - const readyFilePath = tmpFile(`${picFilePrefix}.ready`); - const serverProcess = spawn(binPath, ['--pid', pid.toString()]); + const serverProcess = spawn(binPath, [ + '--pid', + pid.toString(), + '--port-file', + portFilePath, + ]); if (options.showRuntimeLogs) { serverProcess.stdout.pipe(process.stdout); @@ -93,18 +97,36 @@ export class PocketIcServer { throw new BinStartError(error); }); - return await poll(async () => { - const isPocketIcReady = await exists(readyFilePath); - - if (isPocketIcReady) { - const portString = await readFileAsString(portFilePath); - const port = parseInt(portString); - - return new PocketIcServer(serverProcess, port); - } - - throw new BinTimeoutError(); - }); + return await poll( + async () => { + console.log('Polling for file'); + const isPocketIcReady = await exists(portFilePath); + console.log('File exists', isPocketIcReady); + + if (isPocketIcReady) { + console.log('Pocketic ready'); + const portString = await readFileAsString(portFilePath); + console.log('Port string', portString); + const port = parseInt(portString); + console.log('Port number', port); + + if (Number.isNaN(port)) { + console.log('Port is NaN, throwing error'); + throw new BinTimeoutError(); + } + + console.log('Creating server'); + return new PocketIcServer(serverProcess, port); + } + + console.log('Pocketic not ready throwing error'); + throw new BinTimeoutError(); + }, + { + intervalMs: BIN_POLL_INTERVAL_MS, + timeoutMs: BIN_POLL_TIMEOUT_MS, + }, + ); } /** @@ -150,6 +172,9 @@ export class PocketIcServer { } } +const BIN_POLL_INTERVAL_MS = 20; +const BIN_POLL_TIMEOUT_MS = 5 * 1_000; // 5 seconds + class NullStream extends Writable { _write( _chunk: any, diff --git a/packages/pic/src/pocket-ic-types.ts b/packages/pic/src/pocket-ic-types.ts index f3d3586..0323a75 100644 --- a/packages/pic/src/pocket-ic-types.ts +++ b/packages/pic/src/pocket-ic-types.ts @@ -2,81 +2,55 @@ import { Principal } from '@dfinity/principal'; import { ActorInterface, Actor } from './pocket-ic-actor'; import { IDL } from '@dfinity/candid'; +//#region CreateInstance + /** * Options for creating a PocketIc instance. */ export interface CreateInstanceOptions { /** - * Whether to setup an NNS subnet or not. - * Default is `false`. + * Configuration options for creating an NNS subnet. + * If no config is provided, the NNS subnet is not setup. */ - nns?: - | boolean - | { - /** - * The path to the NNS subnet state. - * - * This directory should have the following structure: - * ```text - * |-- backups/ - * |-- checkpoints/ - * |-- diverged_checkpoints/ - * |-- diverged_state_markers/ - * |-- fs_tmp/ - * |-- page_deltas/ - * |-- tip/ - * |-- tmp/ - * |-- states_metadata.pbuf - * ``` - */ - fromPath: string; - - /** - * The subnet ID to setup the NNS subnet on. - * - * The value can be obtained, e.g., via the following command: - * ```bash - * ic-regedit snapshot | jq -r ".nns_subnet_id" - * ``` - */ - subnetId: Principal; - }; + nns?: NnsSubnetConfig; /** - * Whether to setup an SNS subnet or not. - * Default is `false`. + * Configuration options for creating an SNS subnet. + * If no config is provided, the SNS subnet is not setup. */ - sns?: boolean; + sns?: SnsSubnetConfig; /** - * Whether to setup an II subnet or not. - * Default is `false`. + * Configuration options for creating an II subnet. + * If no config is provided, the II subnet is not setup. */ - ii?: boolean; + ii?: IiSubnetConfig; /** - * Whether to setup a Fiduciary subnet or not. - * Default is `false`. + * Configuration options for creating a Fiduciary subnet. + * If no config is provided, the Fiduciary subnet is not setup. */ - fiduciary?: boolean; + fiduciary?: FiduciarySubnetConfig; /** - * Whether to setup a Bitcoin subnet or not. - * Default is `false`. + * Configuration options for creating a Bitcoin subnet. + * If no config is provided, the Bitcoin subnet is not setup. */ - bitcoin?: boolean; + bitcoin?: BitcoinSubnetConfig; /** - * The number of system subnets to setup. - * Default is `0`. + * Configuration options for creating system subnets. + * A system subnet will be created for each configuration object provided. + * If no config objects are provided, no system subnets are setup. */ - system?: number; + system?: SystemSubnetConfig[]; /** - * The number of application subnets to setup. - * Default is `1`. + * Configuration options for creating application subnets. + * An application subnet will be created for each configuration object provided. + * If no config objects are provided, no application subnets are setup. */ - application?: number; + application?: ApplicationSubnetConfig[]; /** * How long the PocketIC client should wait for a response from the server. @@ -84,6 +58,169 @@ export interface CreateInstanceOptions { processingTimeoutMs?: number; } +/** + * Common options for creating a subnet. + */ +export interface SubnetConfig< + T extends NewSubnetStateConfig | FromPathSubnetStateConfig = + | NewSubnetStateConfig + | FromPathSubnetStateConfig, +> { + /** + * Whether to enable deterministic time slicing. + * Defaults to `true`. + */ + enableDeterministicTimeSlicing?: boolean; + + /** + * Whether to enable benchmarking instruction limits. + * Defaults to `false`. + */ + enableBenchmarkingInstructionLimits?: boolean; + + /** + * The state configuration for the subnet. + */ + state: T; +} + +/** + * Options for creating an NNS subnet. + */ +export type NnsSubnetConfig = SubnetConfig; + +/** + * Options for an NNS subnet's state. + */ +export type NnsSubnetStateConfig = + | NewSubnetStateConfig + | FromPathSubnetStateConfig; + +/** + * Options for creating an SNS subnet. + */ +export type SnsSubnetConfig = SubnetConfig; + +/** + * Options for an SNS subnet's state. + */ +export type SnsSubnetStateConfig = NewSubnetStateConfig; + +/** + * Options for creating an II subnet. + */ +export type IiSubnetConfig = SubnetConfig; + +/** + * Options for an II subnet's state. + */ +export type IiSubnetStateConfig = NewSubnetStateConfig; + +/** + * Options for creating a Fiduciary subnet. + */ +export type FiduciarySubnetConfig = SubnetConfig; + +/** + * Options for a Fiduciary subnet's state. + */ +export type FiduciarySubnetStateConfig = NewSubnetStateConfig; + +/** + * Options for creating a Bitcoin subnet. + */ +export type BitcoinSubnetConfig = SubnetConfig; + +/** + * Options for a Bitcoin subnet's state. + */ +export type BitcoinSubnetStateConfig = NewSubnetStateConfig; + +/** + * Options for creating a system subnet. + */ +export type SystemSubnetConfig = SubnetConfig; + +/** + * Options for a system subnet's state. + */ +export type SystemSubnetStateConfig = NewSubnetStateConfig; + +/** + * Options for creating an application subnet. + */ +export type ApplicationSubnetConfig = + SubnetConfig; + +/** + * Options for an application subnet's state. + */ +export type ApplicationSubnetStateConfig = NewSubnetStateConfig; + +/** + * Options for creating a new subnet an empty state. + */ +export interface NewSubnetStateConfig { + /** + * The type of subnet state to initialize the subnet with. + */ + type: SubnetStateType.New; +} + +/** + * Options for creating a subnet from an existing state on the filesystem. + */ +export interface FromPathSubnetStateConfig { + /** + * The type of subnet state to initialize the subnet with. + */ + type: SubnetStateType.FromPath; + + /** + * The path to the subnet state. + * + * This directory should have the following structure: + * ```text + * |-- backups/ + * |-- checkpoints/ + * |-- diverged_checkpoints/ + * |-- diverged_state_markers/ + * |-- fs_tmp/ + * |-- page_deltas/ + * |-- tip/ + * |-- tmp/ + * |-- states_metadata.pbuf + * ``` + */ + path: string; + + /** + * The subnet ID to setup the subnet on. + * + * The value can be obtained, e.g., via the following command for an NNS subnet: + * ```bash + * ic-regedit snapshot | jq -r ".nns_subnet_id" + * ``` + */ + subnetId: Principal; +} + +/** + * The type of state to initialize a subnet with. + */ +export enum SubnetStateType { + /** + * Create a new subnet with an empty state. + */ + New = 'new', + + /** + * Load existing subnet state from the given path. + * The path must be on a filesystem accessible by the PocketIC server. + */ + FromPath = 'fromPath', +} + /** * The topology of a subnet. */ @@ -152,6 +289,8 @@ export enum SubnetType { System = 'System', } +//#endregion CreateInstance + /** * Options for setting up a canister. */ diff --git a/packages/pic/src/util/poll.ts b/packages/pic/src/util/poll.ts index 7c946e6..b5dce6a 100644 --- a/packages/pic/src/util/poll.ts +++ b/packages/pic/src/util/poll.ts @@ -1,19 +1,38 @@ export interface PollOptions { - intervalMs?: number; - timeoutMs?: number; + intervalMs: number; + timeoutMs: number; } -const DEFAULT_POLL_INTERVAL_MS = 20; -const DEFAULT_POLL_TIMEOUT_MS = 5_000; - export async function poll any>( cb: T, - options?: PollOptions, + { intervalMs, timeoutMs }: PollOptions, ): Promise> { - const intervalMs = options?.intervalMs ?? DEFAULT_POLL_INTERVAL_MS; - const timeoutMs = options?.timeoutMs ?? DEFAULT_POLL_TIMEOUT_MS; const startTimeMs = Date.now(); + return new Promise((resolve, reject) => { + async function runPoll(): Promise { + const currentTimeMs = Date.now(); + + try { + return resolve(await cb()); + } catch (e) { + if (currentTimeMs - startTimeMs >= timeoutMs) { + return reject(e); + } + + setTimeout(runPoll, intervalMs); + } + } + + runPoll(); + }); +} + +export async function dirtyPoll any>( + cb: T, + { intervalMs, timeoutMs }: PollOptions, +): Promise> { + const startTimeMs = Date.now(); return new Promise((resolve, reject) => { async function runPoll(): Promise { const currentTimeMs = Date.now();