UNPKG

@informalsystems/quint

Version:

Core tool for the Quint specification language

415 lines 19.7 kB
"use strict"; /* ---------------------------------------------------------------------------------- * Copyright 2023 Informal Systems * Licensed under the Apache License, Version 2.0. * See LICENSE in the project root for license information. * --------------------------------------------------------------------------------- */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.connect = exports.DEFAULT_APALACHE_VERSION_TAG = exports.serverEndpointToConnectionString = exports.createConfig = exports.parseServerEndpoint = void 0; /** * Interface to Apalache * * This functionality is enabled by managing and interacting with the Apalache * server. * * @author Shon Feder, Informal Systems, 2024 * @author Igor Konnov, konnov.phd, 2024 * * @module */ const either_1 = require("@sweet-monads/either"); const path_1 = __importDefault(require("path")); const fs_1 = __importDefault(require("fs")); // TODO: used by GitHub api approach: https://github.com/informalsystems/quint/issues/1124 // import semver from 'semver' const promises_1 = require("stream/promises"); const child_process_1 = __importDefault(require("child_process")); const tar = __importStar(require("tar")); const grpc = __importStar(require("@grpc/grpc-js")); const proto = __importStar(require("@grpc/proto-loader")); const protobufDescriptor = __importStar(require("protobufjs/ext/descriptor")); const promises_2 = require("timers/promises"); const util_1 = require("util"); const verbosity_1 = require("./verbosity"); const config_1 = require("./config"); /** * Parse an endpoint URL in the format hostname:port * @param input the string to parse * @returns either `left(error)`, or `right(ServerEndpoint)` */ function parseServerEndpoint(input) { const m = /^([a-zA-Z0-9.]*):([0-9]+)$/.exec(input); if (m) { const port = Number.parseInt(m[2]); if (port > 65535) { return (0, either_1.left)(`Invalid port number ${port} in ${input}`); } else { return (0, either_1.right)({ hostname: m[1], port }); } } else { return (0, either_1.left)(`Expected hostname:port, found: ${input}`); } } exports.parseServerEndpoint = parseServerEndpoint; function createConfig(loadedConfig, parsedSpec, args, inv = ['q::inv'], init = 'q::init', next = 'q::step') { return { ...loadedConfig, input: { ...(loadedConfig.input ?? {}), source: { type: 'string', format: 'qnt', content: parsedSpec, }, }, checker: { ...(loadedConfig.checker ?? {}), length: args.maxSteps, init: init, next: next, inv: inv, 'temporal-props': args.temporal ? ['q::temporalProps'] : undefined, tuning: { ...(loadedConfig.checker?.tuning ?? {}), 'search.simulation': args.randomTransitions ? 'true' : 'false', }, }, }; } exports.createConfig = createConfig; /** * Convert an endpoint to a GRPC connection string. * @param endpoint an endpoint * @returns the connection string expected by the Apalache server API */ function serverEndpointToConnectionString(endpoint) { return `${endpoint.hostname}:${endpoint.port}`; } exports.serverEndpointToConnectionString = serverEndpointToConnectionString; exports.DEFAULT_APALACHE_VERSION_TAG = '0.47.2'; function handleVerificationFailure(failure) { switch (failure.pass_name) { case 'SanyParser': return { explanation: `internal error: while parsing in Apalache:\n'${failure.error_data}'\nPlease report an issue: https://github.com/informalsystems/quint/issues/new`, errors: [], }; case 'TypeCheckerSnowcat': return { explanation: `internal error: while type checking in Apalache:\n'${failure.error_data}'\nPlease report an issue: https://github.com/informalsystems/quint/issues/new`, errors: [], }; case 'BoundedChecker': switch (failure.error_data.checking_result) { case 'Error': return { explanation: 'found a counterexample', traces: failure.error_data.counterexamples, errors: [] }; case 'Deadlock': return { explanation: 'reached a deadlock', traces: failure.error_data.counterexamples, errors: [] }; default: throw new Error(`internal error: unhandled verification error ${failure.error_data.checking_result}`); } default: throw new Error(`internal error: unhandled verification error at pass ${failure.pass_name}`); } } async function handleResponse(response) { if (response.result == 'success') { const success = JSON.parse(response.success); return (0, either_1.right)(success); } else { switch (response.failure.errorType) { case 'UNEXPECTED': { const errData = JSON.parse(response.failure.data); // Check for the specific assignment error pattern const assignmentErrorMatch = errData.msg.match(/Assignment error: <\[UNKNOWN\]>: (?:\w+::)*(\w+)' is used before it is assigned/); if (assignmentErrorMatch) { const [, variableName] = assignmentErrorMatch; return err(`${variableName} is used before it is assigned. You need to have either \`${variableName} == <expr>\` or \`${variableName}.in(<set>)\` before doing anything else with \`${variableName}\` in your predicate.`); } return err(errData.msg); } case 'PASS_FAILURE': return (0, either_1.left)(handleVerificationFailure(JSON.parse(response.failure.data))); default: // TODO handle other error cases return err(`${response.failure.errorType}: ${response.failure.data}`); } } } // Construct the Apalache interface around the cmdExecutor function apalache(cmdExecutor) { const check = async (c) => { return cmdExecutor.run({ cmd: 'CHECK', config: JSON.stringify(c) }).then(handleResponse); }; const tla = async (c) => { return cmdExecutor.run({ cmd: 'TLA', config: JSON.stringify(c) }).then(handleResponse); }; return { check, tla }; } // Helper to construct errors results function err(explanation, errors = [], traces) { return (0, either_1.left)({ explanation, errors, traces }); } // See https://grpc.io/docs/languages/node/basics/#example-code-and-setup const grpcStubOptions = { keepCase: true, longs: String, enums: String, defaults: true, oneofs: true, }; const GRPC_TIMEOUT_MS = 5000; async function loadProtoDefViaReflection(serverEndpoint, retry) { // Obtain a reflection service client const protoPath = require.resolve('./reflection.proto'); const packageDefinition = proto.loadSync(protoPath, grpcStubOptions); const reflectionProtoDescriptor = grpc.loadPackageDefinition(packageDefinition); const serverReflectionService = reflectionProtoDescriptor.grpc.reflection.v1alpha.ServerReflection; const connectionString = serverEndpointToConnectionString(serverEndpoint); const reflectionClient = new serverReflectionService(connectionString, grpc.credentials.createInsecure()); // Wait for gRPC channel to come up, with 1sec pauses if (retry) { for (;;) { const grpcChannelState = reflectionClient.getChannel().getConnectivityState(true); if (grpcChannelState == grpc.connectivityState.READY) { break; } else { /* I suspect that there is a race with async gRPC code that actually * brings the connection up, so we need to yield control here. In * particular, waiting for the channel to come up in a busy-waiting loop * does NOT work. */ await (0, promises_2.setTimeout)(1000); } } } // Query reflection endpoint return new Promise((resolve, _reject) => { // Add deadline to the call const deadline = new Date(); deadline.setMilliseconds(deadline.getMilliseconds() + GRPC_TIMEOUT_MS); const call = reflectionClient.ServerReflectionInfo({ deadline }); call.on('data', (r) => { call.end(); resolve((0, either_1.right)(r)); }); call.on('error', (e) => resolve(err(`Error querying reflection endpoint: ${e}`))); call.write({ file_containing_symbol: 'shai.cmdExecutor.CmdExecutor' }); }).then(protoDefResponse => protoDefResponse.chain(protoDefResponse => { // Construct a proto definition of the reflection response. if ('error_response' in protoDefResponse) { return err(`Apalache gRPC endpoint is corrupted. Could not extract proto file: ${protoDefResponse.error_response.error_message}`); } // Decode reflection response to FileDescriptorProto let fileDescriptorProtos = protoDefResponse.file_descriptor_response.file_descriptor_proto.map(bytes => protobufDescriptor.FileDescriptorProto.decode(bytes)); // Use proto-loader to load the FileDescriptorProto wrapped in a FileDescriptorSet return (0, either_1.right)(proto.loadFileDescriptorSetFromObject({ file: fileDescriptorProtos }, grpcStubOptions)); })); } function loadGrpcClient(serverEndpoint, protoDef) { const protoDescriptor = grpc.loadPackageDefinition(protoDef); // The cast thru `unknown` lets us convince the type system of anything // See https://basarat.gitbook.io/typescript/type-system/type-assertion#double-assertion const pkg = protoDescriptor.shai; const connectionString = serverEndpointToConnectionString(serverEndpoint); // bump the maximal message sizes, as the Quint backend in Apalache may send very large JSON files const options = { 'grpc.max_receive_message_length': 1024 * 1024 * 1024, 'grpc.max_send_message_length': 1024 * 1024 * 1024, }; const stub = new pkg.cmdExecutor.CmdExecutor(connectionString, grpc.credentials.createInsecure(), options); return { run: (0, util_1.promisify)((data, cb) => stub.run(data, cb)), }; } /** * Connect to the Apalache server, and verify that the gRPC channel is up. * * @param serverEndpoint * a server endpoint * * @param retry Wait for the gRPC connection to come up. * * @returns A promise resolving to a `right<Apalache>` if the connection is * successful, or a `left<ApalacheError>` if not. */ async function tryConnect(serverEndpoint, retry = false) { return (await loadProtoDefViaReflection(serverEndpoint, retry)) .map(protoDef => loadGrpcClient(serverEndpoint, protoDef)) .map(apalache); } function downloadAndUnpackApalache(apalacheVersion) { const url = `https://github.com/apalache-mc/apalache/releases/download/v${apalacheVersion}/apalache.tgz`; return fetch(url) .then( // unpack response body res => (0, promises_1.pipeline)(res.body, tar.extract({ cwd: (0, config_1.apalacheDistDir)(apalacheVersion), strict: true })), error => err(`Error fetching ${url}: ${error}`)) .then(_ => (0, either_1.right)(null), error => err(`Error unpacking .tgz: ${error}`)); } /** * Fetch the latest Apalache release pinned by `APALACHE_VERSION_TAG` from Github. * * @returns A promise resolving to: * - a `right<string>` equal to the path the Apalache dist was unpacked to, * - a `left<ApalacheError>` indicating an error. */ async function fetchApalache(apalacheVersion, verbosityLevel) { const filename = process.platform === 'win32' ? 'apalache-mc.bat' : 'apalache-mc'; const apalacheBinary = path_1.default.join((0, config_1.apalacheDistDir)(apalacheVersion), 'apalache', 'bin', filename); if (fs_1.default.existsSync(apalacheBinary)) { // Use existing download (0, verbosity_1.debugLog)(verbosityLevel, `Using existing Apalache distribution in ${apalacheBinary}`); return (0, either_1.right)(apalacheBinary); } else { fs_1.default.mkdirSync((0, config_1.apalacheDistDir)(apalacheVersion), { recursive: true }); process.stdout.write(`Downloading Apalache distribution ${apalacheVersion}...`); const res = await downloadAndUnpackApalache(apalacheVersion); process.stdout.write(' done.\n'); return res.map(_ => apalacheBinary); } // TODO: This logic makes the CLI tool extremely sensitive to environment. // See https://github.com/informalsystems/quint/issues/1124 // Fetch Github releases // return octokitRequest('GET /repos/apalache-mc/apalache/releases').then( // async resp => { // // Find latest that satisfies `APALACHE_VERSION_TAG` // const versions = resp.data.map((element: any) => element.tag_name) // const latestTaggedVersion = semver.parse(semver.maxSatisfying(versions, APALACHE_VERSION_TAG)) // if (latestTaggedVersion === null) { // return err(`Failed to deteremine a valid semver version vesion from ${versions} and ${APALACHE_VERSION_TAG}`) // } // // Check if we have already downloaded this release // const unpackPath = apalacheDistDir() // const apalacheBinary = path.join(unpackPath, 'apalache', 'bin', 'apalache-mc') // if (fs.existsSync(apalacheBinary)) { // // Use existing download // console.log(`Using existing Apalache distribution in ${unpackPath}`) // return right(unpackPath) // } else { // // No existing download, download Apalache dist // fs.mkdirSync(unpackPath, { recursive: true }) // // Filter release response to get dist archive asset URL // const taggedRelease = resp.data.find((element: any) => element.tag_name == latestTaggedVersion) // const tgzAsset = taggedRelease.assets.find((asset: any) => asset.name == APALACHE_TGZ) // const downloadUrl = tgzAsset.browser_download_url // console.log(`Downloading Apalache distribution from ${downloadUrl}...`) // return fetch(downloadUrl) // .then( // // unpack response body // res => pipeline(res.body, tar.extract({ cwd: unpackPath, strict: true })), // error => err(`Error fetching ${downloadUrl}: ${error}`) // ) // .then( // _ => right(unpackPath), // error => err(`Error unpacking .tgz: ${error}`) // ) // } // }, // error => err(`Error listing the Apalache releases: ${error}`) // ) } /** * Connect to an already running Apalache server, or – if unsuccessful – fetch * Apalache, spawn it, and connect to it. * * If an Apalache server is spawned, the child process exits when the parent process (i.e., this process) terminates. * * @param serverEndpoint * a server endpoint * * @returns A promise resolving to: * - a `right<Apalache>` equal to the path the Apalache dist was unpacked to, * - a `left<ApalacheError>` indicating an error. */ async function connect(serverEndpoint, apalacheVersion, verbosityLevel) { // Try to connect to Shai, and try to ping it const connectionResult = await tryConnect(serverEndpoint); // We managed to connect, simply return this connection if (connectionResult.isRight()) { (0, verbosity_1.debugLog)(verbosityLevel, 'Connecting with existing Apalache server'); return connectionResult; } // Connection or pinging failed, download Apalache (0, verbosity_1.debugLog)(verbosityLevel, 'No running Apalache server found, launching...'); const exeResult = await fetchApalache(apalacheVersion, verbosityLevel); // Launch Apalache from download return exeResult .asyncChain(async (exe) => new Promise((resolve, _) => { (0, verbosity_1.debugLog)(verbosityLevel, `Launching Apalache server on ${serverEndpoint.hostname}:${serverEndpoint.port}`); (0, verbosity_1.debugLog)(verbosityLevel, `Spawning: ${exe}`); // unless non-verbose output is requested, let Apalache write to stdout and stderr const stdio = verbosityLevel >= verbosity_1.verbosity.defaultLevel ? ['ignore', process.stdout, process.stderr] : ['ignore', 'ignore', 'ignore']; // importantly, do not wrap the command in a shell, // as this will prevent the child process from being properly terminated const options = { shell: false, stdio: stdio }; const args = ['server', `--port=${serverEndpoint.port}`]; const apalache = child_process_1.default.spawn(exe, args, options); // Exit handler that kills Apalache if Quint exists function exitHandler() { (0, verbosity_1.debugLog)(verbosityLevel, 'Shutting down Apalache server'); try { apalache.kill('SIGTERM'); } catch (error) { // ESRCH is raised if no process with `pid` exists, i.e., // if Apalache server exited on its own if (error.code == 'ESRCH') { (0, verbosity_1.debugLog)(verbosityLevel, 'Apalache already exited'); } else { throw error; } } } if (apalache.pid) { // Apalache launched successfully (0, verbosity_1.debugLog)(verbosityLevel, `Started Apalache server on pid=${apalache.pid}`); // Install exit handler that kills Apalache if Quint exists process.on('exit', exitHandler.bind(null)); process.on('SIGINT', exitHandler.bind(null)); process.on('SIGUSR1', exitHandler.bind(null)); process.on('SIGUSR2', exitHandler.bind(null)); process.on('uncaughtException', exitHandler.bind(null)); resolve((0, either_1.right)(void 0)); } // If Apalache fails to spawn, `apalache.pid` is undefined and 'error' is // emitted. apalache.on('error', error => resolve(err(`Failed to launch Apalache server: ${error.message}`))); })) .then((0, either_1.chain)(() => tryConnect(serverEndpoint, true))); } exports.connect = connect; //# sourceMappingURL=apalache.js.map