@moonwall/cli
Version:
Testing framework for the Moon family of projects
258 lines (254 loc) • 11.2 kB
JavaScript
// 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
};