defmodule Mix.Tasks.Raxol.Memory.Stability do alias Raxol.Utils.MemoryFormatter @moduledoc """ Long-running memory stability tests for detecting memory leaks and performance degradation. This task runs extended memory tests that simulate real-world usage patterns over extended periods to detect memory leaks, performance degradation, and stability issues. ## Usage mix raxol.memory.stability mix raxol.memory.stability --duration 3600 mix raxol.memory.stability --scenario vim_session mix raxol.memory.stability --with-profiling ## Options * `--duration` - Test duration in seconds (default: 1800 = 30 minutes) * `--scenario` - Specific scenario to test (vim_session, log_streaming, interactive_shell) * `--with-profiling` - Enable detailed memory profiling * `--output` - Output directory for results (default: bench/stability) * `--interval` - Measurement interval in seconds (default: 30) * `--memory-threshold` - Memory growth threshold in MB (default: 10) ## Test Scenarios ### vim_session Simulates a Vim editing session with: - File editing operations - Syntax highlighting - Buffer management - Search and replace operations ### log_streaming Simulates continuous log streaming with: - High-frequency text updates - ANSI color processing - Buffer scrolling - Pattern matching ### interactive_shell Simulates interactive shell usage with: - Command execution - Output processing - History management - Tab completion ## Exit Codes - 0: Stability test passed - 1: Memory leak detected - 2: Performance degradation detected - 3: Test execution failed """ use Mix.Task alias Raxol.Terminal.{ANSI.Utils.AnsiParser, Cursor.Manager} @shortdoc "Run long-running memory stability tests" @spec run(list()) :: no_return() def run(args) do {opts, _, _} = OptionParser.parse(args, switches: [ duration: :integer, scenario: :string, with_profiling: :boolean, output: :string, interval: :integer, memory_threshold: :float, help: :boolean ], aliases: [d: :duration, s: :scenario, o: :output, h: :help] ) if opts[:help] do print_help() System.halt(0) end _ = Application.ensure_all_started(:raxol) config = build_config(opts) Mix.shell().info("Memory Stability Test") Mix.shell().info("====================") Mix.shell().info( "Duration: #{config.duration}s (#{Float.round(config.duration / 60, 1)} minutes)" ) Mix.shell().info("Scenario: #{config.scenario}") Mix.shell().info("Measurement interval: #{config.interval}s") Mix.shell().info("Memory threshold: #{config.memory_threshold}MB growth") try do results = run_stability_test(config) exit_code = analyze_results(results, config) save_results(results, config) System.halt(exit_code) rescue error -> Mix.shell().error("Stability test failed: #{inspect(error)}") System.halt(3) end end defp build_config(opts) do %{ duration: Keyword.get(opts, :duration, 1800), scenario: Keyword.get(opts, :scenario, "vim_session"), with_profiling: Keyword.get(opts, :with_profiling, false), output: Keyword.get(opts, :output, "bench/stability"), interval: Keyword.get(opts, :interval, 30), memory_threshold: Keyword.get(opts, :memory_threshold, 10.0) } end defp run_stability_test(config) do _ = File.mkdir_p(config.output) initial_memory = get_memory_info() start_time = System.monotonic_time(:millisecond) Mix.shell().info("Starting stability test...") Mix.shell().info("Initial memory: #{format_memory(initial_memory.total)}") # Start the scenario task scenario_task = Task.async(fn -> run_scenario(config.scenario, config.duration) end) # Start memory monitoring monitoring_task = Task.async(fn -> monitor_memory(config, start_time) end) # Wait for both tasks to complete scenario_result = Task.await(scenario_task, (config.duration + 60) * 1000) memory_measurements = Task.await(monitoring_task, (config.duration + 60) * 1000) final_memory = get_memory_info() end_time = System.monotonic_time(:millisecond) %{ config: config, start_time: start_time, end_time: end_time, duration: end_time - start_time, initial_memory: initial_memory, final_memory: final_memory, memory_measurements: memory_measurements, scenario_result: scenario_result } end defp run_scenario("vim_session", duration), do: simulate_vim_session(duration) defp run_scenario("log_streaming", duration), do: simulate_log_streaming(duration) defp run_scenario("interactive_shell", duration), do: simulate_interactive_shell(duration) defp run_scenario(scenario, _duration) do Mix.shell().error("Unknown scenario: #{scenario}") %{error: "unknown_scenario"} end defp simulate_vim_session(duration) do Mix.shell().info("Simulating Vim session...") buffer = Raxol.Terminal.ScreenBuffer.new(120, 40) start_time = System.monotonic_time(:millisecond) operations_count = 0 vim_operations = [ &vim_write_text/1, &vim_navigate/1, &vim_search_replace/1, &vim_syntax_highlight/1, &vim_buffer_management/1 ] Stream.repeatedly(fn -> Enum.random(vim_operations) end) |> Stream.scan({buffer, operations_count}, fn operation, {current_buffer, count} -> if System.monotonic_time(:millisecond) - start_time < duration * 1000 do updated_buffer = operation.(current_buffer) # Periodic garbage collection if rem(count, 100) == 0 do :erlang.garbage_collect() end {updated_buffer, count + 1} else {current_buffer, count} end end) |> Stream.take_while(fn {_buffer, _count} -> System.monotonic_time(:millisecond) - start_time < duration * 1000 end) |> Enum.to_list() |> List.last() |> case do {final_buffer, final_count} -> %{buffer: final_buffer, operations: final_count, type: "vim_session"} _ -> %{operations: 0, type: "vim_session"} end end defp simulate_log_streaming(duration) do Mix.shell().info("Simulating log streaming...") buffer = Raxol.Terminal.ScreenBuffer.new(100, 50) start_time = System.monotonic_time(:millisecond) _lines_processed = 0 log_levels = ["INFO", "WARN", "ERROR", "DEBUG"] # Green, Yellow, Red, Cyan color_codes = [32, 33, 31, 36] Stream.iterate(0, &(&1 + 1)) |> Stream.map(fn line_num -> if System.monotonic_time(:millisecond) - start_time < duration * 1000 do level = Enum.random(log_levels) color = Enum.random(color_codes) timestamp = DateTime.utc_now() |> DateTime.to_iso8601() log_line = "\e[#{color}m[#{timestamp}] #{level}: Log entry #{line_num}\e[0m" # Process ANSI sequences _parsed = AnsiParser.parse(log_line) # Write to buffer (simulate scrolling) _row = rem(line_num, 50) case Raxol.Terminal.ScreenBuffer.write(buffer, log_line) do {:ok, updated_buffer} -> {updated_buffer, line_num + 1} _ -> {buffer, line_num + 1} end else {buffer, line_num} end end) |> Stream.take_while(fn {_buffer, _count} -> System.monotonic_time(:millisecond) - start_time < duration * 1000 end) |> Enum.to_list() |> List.last() |> case do {final_buffer, final_count} -> %{buffer: final_buffer, lines: final_count, type: "log_streaming"} _ -> %{lines: 0, type: "log_streaming"} end end defp simulate_interactive_shell(duration) do Mix.shell().info("Simulating interactive shell...") buffer = Raxol.Terminal.ScreenBuffer.new(80, 24) start_time = System.monotonic_time(:millisecond) _commands_executed = 0 shell_commands = [ "ls -la", "ps aux", "find . -name '*.ex'", "grep -r 'defmodule' lib/", "cat README.md", "tail -f /var/log/system.log", "top -l 1" ] Stream.iterate(0, &(&1 + 1)) |> Stream.map(fn cmd_num -> if System.monotonic_time(:millisecond) - start_time < duration * 1000 do command = Enum.random(shell_commands) # Simulate command prompt prompt = "$ #{command}" # Simulate command output output_lines = generate_command_output(command, cmd_num) # Write to buffer updated_buffer = Enum.reduce([prompt | output_lines], buffer, fn line, acc_buffer -> # Each command takes ~3 lines _row = rem(cmd_num * 3, 24) case Raxol.Terminal.ScreenBuffer.write(acc_buffer, line) do {:ok, new_buffer} -> new_buffer _ -> acc_buffer end end) # Simulate typing delay Process.sleep(100) {updated_buffer, cmd_num + 1} else {buffer, cmd_num} end end) |> Stream.take_while(fn {_buffer, _count} -> System.monotonic_time(:millisecond) - start_time < duration * 1000 end) |> Enum.to_list() |> List.last() |> case do {final_buffer, final_count} -> %{ buffer: final_buffer, commands: final_count, type: "interactive_shell" } _ -> %{commands: 0, type: "interactive_shell"} end end # Vim operation helpers defp vim_write_text(buffer) do text = "def function_#{:rand.uniform(1000)}(param) do\n # Implementation here\nend" case Raxol.Terminal.ScreenBuffer.write(buffer, text) do {:ok, updated_buffer} -> updated_buffer _ -> buffer end end defp vim_navigate(buffer) do # Simulate cursor movements (doesn't modify buffer) cursor = Manager.new() _moved_cursor = cursor |> Manager.move_to(:rand.uniform(120), :rand.uniform(40)) |> Manager.move_to(:rand.uniform(80), :rand.uniform(40)) buffer end defp vim_search_replace(buffer) do # Simulate search and replace operation _search_term = "function_#{:rand.uniform(100)}" _replace_term = "method_#{:rand.uniform(100)}" # This would normally search and replace in buffer # For simulation, just return the buffer buffer end defp vim_syntax_highlight(buffer) do # Simulate syntax highlighting by parsing ANSI sequences highlight_sequences = [ # Blue for keywords "\e[34mdef\e[0m", # Green for strings "\e[32m'string'\e[0m", # Yellow for comments "\e[33m# comment\e[0m" ] Enum.each(highlight_sequences, &AnsiParser.parse/1) buffer end defp vim_buffer_management(buffer) do # Simulate buffer operations like clear/resize case :rand.uniform(3) do 1 -> Raxol.Terminal.ScreenBuffer.clear(buffer) _ -> buffer end end defp generate_command_output(command, cmd_num) do case command do "ls -la" -> [ "total 48", "drwxr-xr-x 8 user staff 256 Sep 16 10:30 .", "drwxr-xr-x 3 user staff 96 Sep 16 10:29 .." ] "ps aux" -> [ "USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND", "user #{1000 + cmd_num} 0.0 0.1 12345 1234 pts/0 S 10:30 0:00 bash" ] _ -> [ "Command output line 1 for #{command}", "Command output line 2 for #{command}" ] end end defp monitor_memory(config, start_time) do measurements = [] monitor_memory_loop(config, start_time, measurements) end defp monitor_memory_loop(config, start_time, measurements) do current_time = System.monotonic_time(:millisecond) elapsed = (current_time - start_time) / 1000 if elapsed < config.duration do memory_info = get_memory_info() measurement = %{ timestamp: current_time, elapsed: elapsed, memory: memory_info } updated_measurements = [measurement | measurements] if config.with_profiling do Mix.shell().info( "Memory at #{Float.round(elapsed, 1)}s: #{format_memory(memory_info.total)}" ) end Process.sleep(config.interval * 1000) monitor_memory_loop(config, start_time, updated_measurements) else Enum.reverse(measurements) end end defp get_memory_info do %{ total: :erlang.memory(:total), processes: :erlang.memory(:processes), system: :erlang.memory(:system), atom: :erlang.memory(:atom), binary: :erlang.memory(:binary), ets: :erlang.memory(:ets) } end defp analyze_results(results, config) do Mix.shell().info("\nAnalyzing stability test results...") memory_analysis = analyze_memory_trend(results.memory_measurements) leak_detection = detect_memory_leak(results, config) performance_analysis = analyze_performance(results) Mix.shell().info("\nMemory Analysis:") Mix.shell().info( " Initial: #{format_memory(results.initial_memory.total)}" ) Mix.shell().info(" Final: #{format_memory(results.final_memory.total)}") Mix.shell().info(" Growth: #{format_memory(memory_analysis.total_growth)}") Mix.shell().info( " Growth rate: #{Float.round(memory_analysis.growth_rate_mb_per_hour, 2)} MB/hour" ) case {leak_detection.status, performance_analysis.status} do {:leak_detected, _} -> Mix.shell().info("Result: Memory leak detected!") 1 {_, :degradation_detected} -> Mix.shell().info("Result: Performance degradation detected!") 2 _ -> Mix.shell().info("Result: Stability test passed") 0 end end defp analyze_memory_trend(measurements) do if length(measurements) < 2 do %{total_growth: 0, growth_rate_mb_per_hour: 0.0} else first = List.first(measurements) last = List.last(measurements) total_growth = last.memory.total - first.memory.total elapsed_hours = (last.elapsed - first.elapsed) / 3600 growth_rate_mb_per_hour = if elapsed_hours > 0 do total_growth / 1_000_000 / elapsed_hours else 0.0 end %{ total_growth: total_growth, growth_rate_mb_per_hour: growth_rate_mb_per_hour, measurements_count: length(measurements) } end end defp detect_memory_leak(results, config) do growth_mb = (results.final_memory.total - results.initial_memory.total) / 1_000_000 if growth_mb > config.memory_threshold do %{ status: :leak_detected, growth_mb: growth_mb, threshold_mb: config.memory_threshold } else %{ status: :no_leak, growth_mb: growth_mb, threshold_mb: config.memory_threshold } end end defp analyze_performance(results) do # Analyze if operations per second degraded over time # This is a simplified analysis - could be more sophisticated case results.scenario_result do %{operations: ops} when ops > 0 -> duration_hours = results.duration / (1000 * 3600) ops_per_hour = ops / duration_hours # If less than 100 operations per hour, consider it degraded if ops_per_hour < 100 do %{status: :degradation_detected, ops_per_hour: ops_per_hour} else %{status: :normal, ops_per_hour: ops_per_hour} end _ -> %{status: :unknown} end end defp save_results(results, config) do timestamp = DateTime.utc_now() |> DateTime.to_iso8601() |> String.replace(":", "-") filename = "stability_#{config.scenario}_#{timestamp}.json" output_path = Path.join(config.output, filename) content = Jason.encode!(results, pretty: true) File.write!(output_path, content) Mix.shell().info("Results saved to: #{output_path}") end defp format_memory(bytes), do: MemoryFormatter.format_memory(bytes) defp print_help do Mix.shell().info(@moduledoc) end end