Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ jobs:
env:
NODE_OPTIONS: "--no-deprecation"
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
MOONBEAM_HTTP_ENDPOINT: ${{ secrets.MOONBEAM_HTTP_ENDPOINT }}
strategy:
fail-fast: false
matrix:
Expand Down
16 changes: 16 additions & 0 deletions packages/cli/src/cmds/runTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ export async function executeTests(env: Environment, testRunArgs?: testRunArgs &
.setTimeout(env.timeout || globalConfig.defaultTestTimeout)
.setInclude(env.include || ["**/*{test,spec,test_,test-}*{ts,mts,cts}"])
.addThreadConfig(env.multiThreads)
.setCacheImports(env.cacheImports)
.addVitestPassthroughArgs(env.vitestArgs)
.build();

Expand Down Expand Up @@ -311,6 +312,21 @@ class VitestOptionsBuilder {
return this;
}

setCacheImports(enabled?: boolean): this {
if (enabled) {
this.options.deps = {
optimizer: {
ssr: {
enabled: true,
include: ["viem", "ethers"],
},
web: { enabled: false },
},
};
}
return this;
}

build(): UserConfig {
return this.options;
}
Expand Down
118 changes: 106 additions & 12 deletions packages/cli/src/internal/commandParsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,23 @@ import type {
LaunchOverrides,
ForkConfig,
} from "@moonwall/types";
import chalk from "chalk";
import { createLogger } from "@moonwall/util";
import path from "node:path";
import net from "node:net";
import { Effect } from "effect";
import { standardRepos } from "../lib/repoDefinitions";
import { shardManager } from "../lib/shardManager";
import invariant from "tiny-invariant";
import { StartupCacheService, StartupCacheServiceLive } from "./effect/StartupCacheService.js";

const logger = createLogger({ name: "commandParsers" });

export function parseZombieCmd(launchSpec: ZombieLaunchSpec) {
if (launchSpec) {
return { cmd: launchSpec.configPath };
}
throw new Error(
`No ZombieSpec found in config. \n Are you sure your ${chalk.bgWhiteBright.blackBright(
"moonwall.config.json"
)} file has the correct "configPath" in zombieSpec?`
"No ZombieSpec found in config. Are you sure your moonwall.config.json file has the correct 'configPath' in zombieSpec?"
);
}

Expand Down Expand Up @@ -122,8 +124,8 @@ export class LaunchCommandParser {
}

private print() {
console.log(chalk.cyan(`Command to run is: ${chalk.bold(this.cmd)}`));
console.log(chalk.cyan(`Arguments are: ${chalk.bold(this.args.join(" "))}`));
logger.debug(`Command to run: ${this.cmd}`);
logger.debug(`Arguments: ${this.args.join(" ")}`);
return this;
}

Expand All @@ -143,6 +145,99 @@ export class LaunchCommandParser {
}
}

/**
* Cache startup artifacts if enabled in launchSpec.
* This uses an Effect-based service that caches artifacts by binary hash.
*
* When cacheStartupArtifacts is enabled, this generates:
* 1. Precompiled WASM for the runtime
* 2. Raw chain spec to skip genesis WASM compilation
*
* This reduces startup from ~3s to ~200ms (~10x improvement).
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The performance claim of "~10x improvement" (from "~3s to ~200ms") in the comment needs verification. 3000ms to 200ms is actually a 15x improvement, not 10x. Either the comment should be updated to reflect the actual improvement ratio, or the numbers should be corrected.

Consider updating to be more accurate:

 * This reduces startup from ~3s to ~200ms (~15x improvement).

Or if the improvement is actually ~10x:

 * This reduces startup time by approximately 10x.
Suggested change
* This reduces startup from ~3s to ~200ms (~10x improvement).
* This reduces startup from ~3s to ~200ms (~15x improvement).

Copilot uses AI. Check for mistakes.
*/
async withStartupCache(): Promise<LaunchCommandParser> {
if (!this.launchSpec.cacheStartupArtifacts) {
return this;
}

// Skip for Docker images
if (this.launchSpec.useDocker) {
logger.warn("Startup caching is not supported for Docker images, skipping");
return this;
}

// Extract chain argument from existing args (e.g., "--chain=moonbase-dev")
const chainArg = this.args.find((arg) => arg.startsWith("--chain"));
// Check if using --dev flag
const hasDevFlag = this.args.includes("--dev");
// Extract chain name from --chain=XXX or --chain XXX
const existingChainName = chainArg?.match(/--chain[=\s]?(\S+)/)?.[1];
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex pattern --chain[=\s]?(\S+) used to extract the chain name may not handle all valid chain argument formats correctly. Specifically:

  1. The pattern allows optional whitespace or =, but if whitespace is matched, it won't be consumed, causing the chain name to start with a space
  2. The pattern uses \S+ which captures any non-whitespace, but this could capture additional arguments if --chain is followed by a space and the next argument (e.g., --chain moonbase-dev --other-flag could capture moonbase-dev)

Consider using a more precise pattern:

const existingChainName = chainArg?.includes('=') 
  ? chainArg.split('=')[1]
  : this.args[this.args.indexOf(chainArg) + 1];

Or if keeping the regex approach:

const existingChainName = chainArg?.match(/--chain(?:=(\S+)|\s+(\S+))/)?.[1] || chainArg?.match(/--chain(?:=(\S+)|\s+(\S+))/)?.[2];
Suggested change
const existingChainName = chainArg?.match(/--chain[=\s]?(\S+)/)?.[1];
const existingChainName = chainArg
? (chainArg.includes("=")
? chainArg.split("=")[1]
: this.args[this.args.indexOf(chainArg) + 1])
: undefined;

Copilot uses AI. Check for mistakes.

// We can generate raw chain spec for both --dev mode and explicit --chain=XXX
const canGenerateRawSpec = hasDevFlag || !!existingChainName;

const cacheDir =
this.launchSpec.startupCacheDir || path.join(process.cwd(), "tmp", "startup-cache");

const program = StartupCacheService.pipe(
Effect.flatMap((service) =>
service.getCachedArtifacts({
binPath: this.launchSpec.binPath,
chainArg,
cacheDir,
// Generate raw chain spec for faster startup (works for both --dev and --chain=XXX)
generateRawChainSpec: canGenerateRawSpec,
// Pass dev mode flag for proper chain name detection
isDevMode: hasDevFlag,
})
),
Effect.provide(StartupCacheServiceLive)
);

try {
const result = await Effect.runPromise(program);
// --wasmtime-precompiled expects a DIRECTORY, not a file path
// Get the directory containing the precompiled wasm
const precompiledDir = path.dirname(result.precompiledPath);
this.overrideArg(`--wasmtime-precompiled=${precompiledDir}`);

// If we have a raw chain spec, use it for ~10x faster startup
if (result.rawChainSpecPath) {
if (hasDevFlag) {
// Remove --dev flag and add equivalent flags
this.args = this.args.filter((arg) => arg !== "--dev");
this.overrideArg(`--chain=${result.rawChainSpecPath}`);
// Add flags that --dev would normally set
this.args.push("--alice");
this.args.push("--force-authoring");
Comment on lines +211 to +212
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When replacing --dev with a raw chain spec, the code adds --alice and --force-authoring flags using .push() instead of overrideArg(). This could lead to duplicate flags if these arguments already exist in the args array (e.g., from default args or user-specified options).

Use the overrideArg() method consistently to prevent duplicates:

this.overrideArg("--alice");
this.overrideArg("--force-authoring");

However, note that overrideArg() may not handle flag-style arguments without = correctly (see the implementation at line 70-78), which could also cause issues.

Copilot uses AI. Check for mistakes.
this.overrideArg("--rpc-cors=all");
// Use a deterministic node key for consistency
this.overrideArg(
"--node-key=0000000000000000000000000000000000000000000000000000000000000001"
);
} else if (existingChainName) {
// Replace original --chain=XXX with --chain=<raw-spec-path>
this.overrideArg(`--chain=${result.rawChainSpecPath}`);
}
logger.debug(`Using raw chain spec for ~10x faster startup: ${result.rawChainSpecPath}`);
}

// Set cache directory env var for metadata caching in provider factories
process.env.MOONWALL_CACHE_DIR = precompiledDir;
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Setting process.env.MOONWALL_CACHE_DIR as a side effect during argument parsing could lead to unexpected behavior in multi-environment or concurrent test scenarios. Environment variables are global to the process, so this could cause cache directory conflicts when running multiple test suites in parallel.

Consider alternatives:

  1. Pass the cache directory through a different mechanism (e.g., a context object or dedicated configuration)
  2. Use a namespaced environment variable that includes the test environment ID or process ID
  3. Document this limitation clearly if parallel execution with different cache directories is not supported

Copilot uses AI. Check for mistakes.

logger.debug(
result.fromCache
? `Using cached precompiled WASM: ${result.precompiledPath}`
: `Precompiled WASM created: ${result.precompiledPath}`
);
} catch (error) {
// Log warning but continue without precompilation
logger.warn(`WASM precompilation failed, continuing without: ${error}`);
}

return this;
}

build(): { cmd: string; args: string[]; launch: boolean } {
return {
cmd: this.cmd,
Expand All @@ -160,7 +255,8 @@ export class LaunchCommandParser {
const parser = new LaunchCommandParser(options);
const parsed = await parser
.withPorts()
.then((p) => p.withDefaultForkConfig().withLaunchOverrides());
.then((p) => p.withDefaultForkConfig().withLaunchOverrides())
.then((p) => p.withStartupCache());

if (options.verbose) {
parsed.print();
Expand Down Expand Up @@ -282,11 +378,9 @@ export const getFreePort = async (): Promise<number> => {
// Ensure we stay within a reasonable port range
const startPort = Math.min(calculatedPort, 60000 + shardIndex * 100 + poolId);

if (process.env.DEBUG_MOONWALL_PORTS) {
console.log(
`[DEBUG] Port calculation: shard=${shardIndex + 1}/${totalShards}, pool=${poolId}, final=${startPort}`
);
}
logger.debug(
`Port calculation: shard=${shardIndex + 1}/${totalShards}, pool=${poolId}, final=${startPort}`
);

return getNextAvailablePort(startPort);
};
4 changes: 2 additions & 2 deletions packages/cli/src/internal/effect/NodeReadinessService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,11 +166,11 @@ const attemptReadinessCheck = (
const checkReadyWithRetryInternal = (
config: ReadinessConfig
): Effect.Effect<boolean, NodeReadinessError, Socket.Socket> => {
const maxAttempts = config.maxAttempts || 30;
const maxAttempts = config.maxAttempts || 200;

return attemptReadinessCheck(config).pipe(
Effect.retry(
Schedule.fixed("200 millis").pipe(Schedule.compose(Schedule.recurs(maxAttempts - 1)))
Schedule.fixed("50 millis").pipe(Schedule.compose(Schedule.recurs(maxAttempts - 1)))
),
Effect.catchAll((error) =>
Effect.fail(
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/internal/effect/PortDiscoveryService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,11 @@ const attemptPortDiscovery = (pid: number): Effect.Effect<number, PortDiscoveryE
*/
const discoverPortWithRetry = (
pid: number,
maxAttempts = 600 // 600 attempts × 100ms = 60 seconds (handles parallel startup contention)
maxAttempts = 1200
): Effect.Effect<number, PortDiscoveryError> =>
attemptPortDiscovery(pid).pipe(
Effect.retry(
Schedule.fixed("100 millis").pipe(Schedule.compose(Schedule.recurs(maxAttempts - 1)))
Schedule.fixed("50 millis").pipe(Schedule.compose(Schedule.recurs(maxAttempts - 1)))
),
Effect.catchAll((error) =>
Effect.fail(
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/internal/effect/RpcPortDiscoveryService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ const discoverRpcPortWithRace = (config: {
isEthereumChain: boolean;
maxAttempts?: number;
}): Effect.Effect<number, PortDiscoveryError> => {
const maxAttempts = config.maxAttempts || 600; // Match PortDiscoveryService: 600 attempts × 200ms = 120s
const maxAttempts = config.maxAttempts || 2400;

return getAllPorts(config.pid).pipe(
Effect.flatMap((allPorts) => {
Expand Down Expand Up @@ -236,7 +236,7 @@ const discoverRpcPortWithRace = (config: {
}),
// Retry the entire discovery process
Effect.retry(
Schedule.fixed("200 millis").pipe(Schedule.compose(Schedule.recurs(maxAttempts - 1)))
Schedule.fixed("50 millis").pipe(Schedule.compose(Schedule.recurs(maxAttempts - 1)))
),
Effect.catchAll((error) =>
Effect.fail(
Expand Down
Loading
Loading