UNPKG

@moonwall/cli

Version:

Testing framework for the Moon family of projects

258 lines (254 loc) 11.2 kB
// src/internal/effect/StartupCacheService.ts import { Command, FileSystem as FileSystem2, Path } from "@effect/platform"; import { NodeContext } from "@effect/platform-node"; import { createLogger } from "@moonwall/util"; import { Context, Duration as Duration2, Effect as Effect2, Layer, Option, Stream } from "effect"; import * as crypto from "crypto"; // src/internal/effect/errors.ts import { Data } from "effect"; var PortDiscoveryError = class extends Data.TaggedError("PortDiscoveryError") { }; var NodeLaunchError = class extends Data.TaggedError("NodeLaunchError") { }; var NodeReadinessError = class extends Data.TaggedError("NodeReadinessError") { }; var ProcessError = class extends Data.TaggedError("ProcessError") { }; var StartupCacheError = class extends Data.TaggedError("StartupCacheError") { }; var FileLockError = class extends Data.TaggedError("FileLockError") { }; // src/internal/effect/FileLock.ts import { FileSystem } from "@effect/platform"; import { Duration, Effect, Schedule } from "effect"; import * as os from "os"; var LOCK_MAX_AGE = Duration.minutes(2); var LOCK_POLL_INTERVAL = Duration.millis(500); var isProcessAlive = (pid) => Effect.try(() => { process.kill(pid, 0); return true; }).pipe(Effect.orElseSucceed(() => false)); var isLockStale = (info) => Effect.gen(function* () { const isTimedOut = Date.now() - info.timestamp > Duration.toMillis(LOCK_MAX_AGE); if (isTimedOut) return true; const isSameHost = info.hostname === os.hostname(); if (!isSameHost) return false; const alive = yield* isProcessAlive(info.pid); return !alive; }); var cleanupStaleLock = (lockPath) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const infoPath = `${lockPath}/lock.json`; const exists = yield* fs.exists(infoPath).pipe(Effect.orElseSucceed(() => false)); if (!exists) return; const content = yield* fs.readFileString(infoPath).pipe(Effect.orElseSucceed(() => "")); const info = yield* Effect.try(() => JSON.parse(content)).pipe( Effect.orElseSucceed(() => null) ); if (!info) return; const stale = yield* isLockStale(info); if (stale) { yield* fs.remove(lockPath, { recursive: true }).pipe(Effect.ignore); } }); var writeLockInfo = (lockPath) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const info = { pid: process.pid, timestamp: Date.now(), hostname: os.hostname() }; yield* fs.writeFileString(`${lockPath}/lock.json`, JSON.stringify(info)).pipe(Effect.ignore); }); var tryAcquireLock = (lockPath) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; yield* cleanupStaleLock(lockPath); yield* fs.makeDirectory(lockPath).pipe(Effect.mapError(() => new FileLockError({ reason: "acquisition_failed", lockPath }))); yield* writeLockInfo(lockPath); }); var acquireFileLock = (lockPath, timeout = Duration.minutes(2)) => tryAcquireLock(lockPath).pipe( Effect.retry(Schedule.fixed(LOCK_POLL_INTERVAL).pipe(Schedule.upTo(timeout))), Effect.catchAll(() => Effect.fail(new FileLockError({ reason: "timeout", lockPath }))) ); var releaseFileLock = (lockPath) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; yield* fs.remove(lockPath, { recursive: true }).pipe(Effect.ignore); }); var withFileLock = (lockPath, effect, timeout = Duration.minutes(2)) => Effect.acquireUseRelease( acquireFileLock(lockPath, timeout), () => effect, () => releaseFileLock(lockPath) ); // src/internal/effect/StartupCacheService.ts var logger = createLogger({ name: "StartupCacheService" }); var StartupCacheService = class extends Context.Tag("StartupCacheService")() { }; var hashFile = (filePath) => Effect2.gen(function* () { const fs = yield* FileSystem2.FileSystem; const hash = crypto.createHash("sha256"); yield* fs.stream(filePath).pipe(Stream.runForEach((chunk) => Effect2.sync(() => hash.update(chunk)))); return hash.digest("hex"); }).pipe(Effect2.mapError((cause) => new StartupCacheError({ cause, operation: "hash" }))); var findPrecompiledWasm = (dir) => Effect2.gen(function* () { const fs = yield* FileSystem2.FileSystem; const pathService = yield* Path.Path; const exists = yield* fs.exists(dir); if (!exists) return Option.none(); const files = yield* fs.readDirectory(dir).pipe(Effect2.orElseSucceed(() => [])); const wasmFile = files.find( (f) => f.startsWith("precompiled_wasm_") || f.endsWith(".cwasm") || f.endsWith(".wasm") ); return wasmFile ? Option.some(pathService.join(dir, wasmFile)) : Option.none(); }).pipe(Effect2.catchAll(() => Effect2.succeed(Option.none()))); var checkCache = (cacheDir, hashPath, expectedHash) => Effect2.gen(function* () { const fs = yield* FileSystem2.FileSystem; const savedHash = yield* fs.readFileString(hashPath).pipe(Effect2.orElseSucceed(() => "")); if (savedHash.trim() !== expectedHash) return Option.none(); const wasmPath = yield* findPrecompiledWasm(cacheDir); if (Option.isNone(wasmPath)) return Option.none(); const accessible = yield* fs.access(wasmPath.value).pipe( Effect2.as(true), Effect2.orElseSucceed(() => false) ); return accessible ? wasmPath : Option.none(); }); var runPrecompile = (binPath, chainArg, outputDir) => Effect2.gen(function* () { const fs = yield* FileSystem2.FileSystem; const pathService = yield* Path.Path; const args = chainArg ? ["precompile-wasm", chainArg, outputDir] : ["precompile-wasm", outputDir]; logger.debug(`Precompiling: ${binPath} ${args.join(" ")}`); const startTime = Date.now(); const exitCode = yield* Command.exitCode(Command.make(binPath, ...args)).pipe( Effect2.mapError( (e) => new StartupCacheError({ cause: e, operation: "precompile" }) ) ); const files = yield* fs.readDirectory(outputDir).pipe(Effect2.mapError((e) => new StartupCacheError({ cause: e, operation: "precompile" }))); const wasmFile = files.find( (f) => f.startsWith("precompiled_wasm_") || f.endsWith(".cwasm") || f.endsWith(".wasm") ); if (!wasmFile) { return yield* Effect2.fail( new StartupCacheError({ cause: `precompile-wasm failed (code ${exitCode}): no WASM file generated`, operation: "precompile" }) ); } const wasmPath = pathService.join(outputDir, wasmFile); logger.debug(`Precompiled in ${Date.now() - startTime}ms: ${wasmPath}`); return wasmPath; }); var generateRawChainSpec = (binPath, chainName, outputPath) => Effect2.gen(function* () { const fs = yield* FileSystem2.FileSystem; const args = chainName === "dev" || chainName === "default" ? ["build-spec", "--dev", "--raw"] : ["build-spec", `--chain=${chainName}`, "--raw"]; logger.debug(`Generating raw chain spec: ${binPath} ${args.join(" ")}`); const stdout = yield* Command.string(Command.make(binPath, ...args)).pipe( Effect2.mapError( (e) => new StartupCacheError({ cause: e, operation: "chainspec" }) ) ); if (!stdout.length) { return yield* Effect2.fail( new StartupCacheError({ cause: "build-spec produced no output", operation: "chainspec" }) ); } yield* fs.writeFileString(outputPath, stdout).pipe(Effect2.mapError((e) => new StartupCacheError({ cause: e, operation: "chainspec" }))); return outputPath; }); var maybeGetRawChainSpec = (binPath, chainName, cacheSubDir, shouldGenerate) => Effect2.gen(function* () { if (!shouldGenerate) return Option.none(); const fs = yield* FileSystem2.FileSystem; const pathService = yield* Path.Path; const rawSpecPath = pathService.join(cacheSubDir, `${chainName}-raw.json`); const exists = yield* fs.exists(rawSpecPath).pipe(Effect2.orElseSucceed(() => false)); if (exists) return Option.some(rawSpecPath); return yield* generateRawChainSpec(binPath, chainName, rawSpecPath).pipe( Effect2.map(Option.some), Effect2.catchAll(() => Effect2.succeed(Option.none())) ); }); var getCachedArtifactsImpl = (config) => Effect2.gen(function* () { const fs = yield* FileSystem2.FileSystem; const pathService = yield* Path.Path; const binaryHash = yield* hashFile(config.binPath); const shortHash = binaryHash.substring(0, 12); const chainName = config.isDevMode ? "dev" : config.chainArg?.match(/--chain[=\s]?(\S+)/)?.[1] || "default"; const binName = pathService.basename(config.binPath); const cacheSubDir = pathService.join(config.cacheDir, `${binName}-${chainName}-${shortHash}`); const hashPath = pathService.join(cacheSubDir, "binary.hash"); const lockPath = pathService.join(config.cacheDir, `${binName}-${chainName}.lock`); yield* fs.makeDirectory(cacheSubDir, { recursive: true }).pipe(Effect2.mapError((e) => new StartupCacheError({ cause: e, operation: "cache" }))); const cached = yield* checkCache(cacheSubDir, hashPath, binaryHash); if (Option.isSome(cached)) { logger.debug(`Using cached precompiled WASM: ${cached.value}`); const rawChainSpecPath = yield* maybeGetRawChainSpec( config.binPath, chainName, cacheSubDir, config.generateRawChainSpec ?? false ); return { precompiledPath: cached.value, fromCache: true, rawChainSpecPath: Option.getOrUndefined(rawChainSpecPath) }; } return yield* withFileLock( lockPath, Effect2.gen(function* () { const nowCached = yield* checkCache(cacheSubDir, hashPath, binaryHash); if (Option.isSome(nowCached)) { logger.debug( `Using cached precompiled WASM (created by another process): ${nowCached.value}` ); const rawChainSpecPath2 = yield* maybeGetRawChainSpec( config.binPath, chainName, cacheSubDir, config.generateRawChainSpec ?? false ); return { precompiledPath: nowCached.value, fromCache: true, rawChainSpecPath: Option.getOrUndefined(rawChainSpecPath2) }; } logger.debug("Precompiling WASM (this may take a moment)..."); const wasmPath = yield* runPrecompile(config.binPath, config.chainArg, cacheSubDir); yield* fs.writeFileString(hashPath, binaryHash).pipe(Effect2.mapError((e) => new StartupCacheError({ cause: e, operation: "cache" }))); const rawChainSpecPath = yield* maybeGetRawChainSpec( config.binPath, chainName, cacheSubDir, config.generateRawChainSpec ?? false ); return { precompiledPath: wasmPath, fromCache: false, rawChainSpecPath: Option.getOrUndefined(rawChainSpecPath) }; }), Duration2.minutes(2) ); }); var StartupCacheServiceLive = Layer.succeed(StartupCacheService, { getCachedArtifacts: (config) => getCachedArtifactsImpl(config).pipe( Effect2.mapError( (e) => e._tag === "FileLockError" ? new StartupCacheError({ cause: e, operation: "lock" }) : e ), Effect2.provide(NodeContext.layer) ) }); var StartupCacheServiceTestable = Layer.succeed(StartupCacheService, { getCachedArtifacts: (config) => getCachedArtifactsImpl(config).pipe( Effect2.mapError( (e) => e._tag === "FileLockError" ? new StartupCacheError({ cause: e, operation: "lock" }) : e ) ) }); export { StartupCacheService, StartupCacheServiceLive, StartupCacheServiceTestable };