diff --git a/.gitignore b/.gitignore index b3318cd..064c877 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ lib/rotoscope/rotoscope.so .rubocop-* Gemfile.lock .bundle +coverage/ diff --git a/.rubocop.yml b/.rubocop.yml index 19c633e..845dd97 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -6,8 +6,14 @@ inherit_gem: AllCops: Exclude: - 'tmp/**/*' + - 'test/memory_test.rb' + - 'test/safety_test.rb' -Metrics/LineLength: +Layout/LineLength: + Exclude: + - 'test/**/*' + +Naming/MethodName: Exclude: - 'test/**/*' diff --git a/Rakefile b/Rakefile index c95348b..c2f7b1d 100644 --- a/Rakefile +++ b/Rakefile @@ -36,7 +36,7 @@ require "ruby_memcheck" RubyMemcheck.config(binary_name: "rotoscope") test_config = lambda do |t| - t.test_files = FileList["test/*_test.rb"] + t.test_files = FileList["test/*_test.rb"].exclude("test/memory_test.rb") end Rake::TestTask.new(test: :build, &test_config) @@ -50,4 +50,13 @@ task :rubocop do RuboCop::RakeTask.new end +# ========================================================== +# Benchmarking +# ========================================================== + +desc "Run benchmarks (set BENCH_N for iterations, default 10000)" +task bench: :build do + ruby "bench/benchmark.rb" +end + task(default: [:test, :rubocop]) diff --git a/ext/rotoscope/callsite.c b/ext/rotoscope/callsite.c index 0cb283b..2a38597 100644 --- a/ext/rotoscope/callsite.c +++ b/ext/rotoscope/callsite.c @@ -4,49 +4,37 @@ #include #include -static VALUE caller_frame(int *line, bool ruby_call) { - VALUE frames[2] = {Qnil, Qnil}; - int lines[2] = {0, 0}; +static VALUE caller_frame(int *line) { + VALUE frame = Qnil; + int frame_line = 0; - // At this point, for ruby calls, the top ruby stack frame is - // for the method being called, so we want to skip that frame - // and get the caller location. This is why we use 1 for ruby - // calls. - // - // However, for C call, the top stack frame is for the caller, - // so we don't want to have to skip over any extra stack frames - // for C calls. - int frame_index = ruby_call ? 1 : 0; + // For ruby calls, the top frame is the callee — skip it to get the caller. + // Ruby 4.0+ fixed rb_profile_frames start argument (ruby-lang bug #14607). + rb_profile_frames(1, 1, &frame, &frame_line); - // There is currently a bug in rb_profile_frames that - // causes the start argument to effectively always - // act as if it were 0, so we need to also get the top - // frame. (https://bugs.ruby-lang.org/issues/14607) - rb_profile_frames(0, frame_index + 1, frames, lines); - - *line = lines[frame_index]; - return frames[frame_index]; + *line = frame_line; + return frame; } rs_callsite_t c_callsite(rb_trace_arg_t *trace_arg) { - int line; - VALUE frame = caller_frame(&line, false); return (rs_callsite_t){ .filepath = rb_tracearg_path(trace_arg), .lineno = FIX2INT(rb_tracearg_lineno(trace_arg)), - .method_name = rb_profile_frame_method_name(frame), - .singleton_p = rb_profile_frame_singleton_method_p(frame), + .method_name = Qnil, + .singleton_p = Qnil, + .profile_frame = Qnil, }; } rs_callsite_t ruby_callsite() { int line; - VALUE frame = caller_frame(&line, true); + VALUE frame = caller_frame(&line); return (rs_callsite_t){ .filepath = rb_profile_frame_path(frame), .lineno = line, - .method_name = rb_profile_frame_method_name(frame), - .singleton_p = rb_profile_frame_singleton_method_p(frame), + .method_name = Qnil, // filled from stack or profile_frame in event_hook + .singleton_p = Qnil, + .profile_frame = frame, }; } diff --git a/ext/rotoscope/callsite.h b/ext/rotoscope/callsite.h index 967a78e..e2fde66 100644 --- a/ext/rotoscope/callsite.h +++ b/ext/rotoscope/callsite.h @@ -9,6 +9,7 @@ typedef struct { unsigned int lineno; VALUE method_name; VALUE singleton_p; + VALUE profile_frame; // for lazy method_name/singleton_p extraction } rs_callsite_t; rs_callsite_t c_callsite(rb_trace_arg_t *trace_arg); diff --git a/ext/rotoscope/method_desc.c b/ext/rotoscope/method_desc.c index 4a4b9af..6bdda9c 100644 --- a/ext/rotoscope/method_desc.c +++ b/ext/rotoscope/method_desc.c @@ -8,4 +8,5 @@ VALUE rs_method_class(rs_method_desc_t *method) { void rs_method_desc_mark(rs_method_desc_t *method) { rb_gc_mark(method->receiver); rb_gc_mark(method->id); + rb_gc_mark(method->class_name); } diff --git a/ext/rotoscope/method_desc.h b/ext/rotoscope/method_desc.h index 44d7411..f5816c7 100644 --- a/ext/rotoscope/method_desc.h +++ b/ext/rotoscope/method_desc.h @@ -7,6 +7,7 @@ typedef struct rs_method_desc_t { VALUE receiver; VALUE id; + VALUE class_name; bool singleton_p; } rs_method_desc_t; diff --git a/ext/rotoscope/rotoscope.c b/ext/rotoscope/rotoscope.c index 37dbb93..c1a31b8 100644 --- a/ext/rotoscope/rotoscope.c +++ b/ext/rotoscope/rotoscope.c @@ -13,11 +13,17 @@ #include "stack.h" VALUE cRotoscope, cTracePoint; -ID id_initialize, id_call; +VALUE str_unknown, str_empty; +ID id_initialize, id_match_p; -static unsigned long current_thread_id() { - return NUM2ULONG(rb_obj_id(rb_thread_current())); -} +// Forward declarations — used in event_hook before definition +VALUE rotoscope_log_call(VALUE self, VALUE io, VALUE excludelist, + VALUE self_obj); +static bool build_csv_line(Rotoscope *config, VALUE buf, VALUE excludelist, + VALUE self_obj); + +#define LOG_BUFFER_FLUSH_SIZE 65536 +static void flush_log_buffer(Rotoscope *config); static VALUE class_path(VALUE klass) { VALUE cached_path = rb_class_path_cached(klass); @@ -42,9 +48,9 @@ static VALUE class2str(VALUE klass) { return class_path(klass); } -static rs_callsite_t tracearg_path(rb_trace_arg_t *trace_arg) { - switch (rb_tracearg_event_flag(trace_arg)) { - case RUBY_EVENT_C_RETURN: +static rs_callsite_t tracearg_path(rb_trace_arg_t *trace_arg, + rb_event_flag_t event_flag) { + switch (event_flag) { case RUBY_EVENT_C_CALL: return c_callsite(trace_arg); default: @@ -59,9 +65,15 @@ static rs_method_desc_t called_method_desc(rb_trace_arg_t *trace_arg) { (RB_TYPE_P(receiver, T_CLASS) || RB_TYPE_P(receiver, T_MODULE)) && SYM2ID(method_id) != id_initialize; + // Pre-compute class name so build_csv_line reads it from the frame + // for both the current receiver and the next event's caller. + VALUE klass = singleton_p ? receiver : rb_obj_class(receiver); + VALUE class_name = class2str(klass); + return (rs_method_desc_t){ .receiver = receiver, .id = method_id, + .class_name = class_name, .singleton_p = singleton_p, }; } @@ -100,7 +112,7 @@ static void event_hook(VALUE tpval, void *data) { return; } - if (config->tid != current_thread_id()) return; + if (config->tid != rb_thread_current()) return; if (in_fork(config)) { rb_tracepoint_disable(config->tracepoint); config->tracing = false; @@ -122,20 +134,67 @@ static void event_hook(VALUE tpval, void *data) { return; } - config->callsite = tracearg_path(trace_arg); + config->callsite = tracearg_path(trace_arg, event_flag); config->caller = rs_stack_peek(&config->stack); + // Derive caller method name from the stack when available. + // For caller_singleton_p, always use the profile frame when we have one + // (it correctly handles define_method/extend edge cases where our stack's + // singleton_p can differ from rb_profile_frame_singleton_method_p). + if (config->caller != NULL) { + config->callsite.method_name = rb_sym2str(config->caller->method.id); + if (config->callsite.profile_frame != Qnil) { + config->callsite.singleton_p = + rb_profile_frame_singleton_method_p(config->callsite.profile_frame); + } else { + config->callsite.singleton_p = + config->caller->method.singleton_p ? Qtrue : Qfalse; + } + } else if (config->callsite.profile_frame != Qnil) { + // Top-level call — no stack caller. Fall back to profile frame. + config->callsite.method_name = + rb_profile_frame_method_name(config->callsite.profile_frame); + config->callsite.singleton_p = + rb_profile_frame_singleton_method_p(config->callsite.profile_frame); + } else if (event_flag == RUBY_EVENT_C_CALL) { + // C_CALL with no stack caller (first call after start_trace is a C call). + // Need rb_profile_frames to get the caller method name. + VALUE frame = Qnil; + int line = 0; + rb_profile_frames(0, 1, &frame, &line); + if (frame != Qnil) { + config->callsite.method_name = rb_profile_frame_method_name(frame); + config->callsite.singleton_p = rb_profile_frame_singleton_method_p(frame); + } + } + rs_method_desc_t method_desc = called_method_desc(trace_arg); rs_stack_push(&config->stack, (rs_stack_frame_t){.method = method_desc}); - rb_funcall(config->trace_proc, id_call, 1, config->self); + // If a native logger is registered, build CSV directly into the shared + // buffer and flush periodically. Avoids per-event rb_funcall + temp string. + if (config->log_io != Qnil) { + if (build_csv_line(config, config->log_buffer, config->log_excludelist, + config->log_self_obj)) { + if (RSTRING_LEN(config->log_buffer) >= LOG_BUFFER_FLUSH_SIZE) { + flush_log_buffer(config); + } + } + } else if (config->trace_proc != Qnil) { + rb_proc_call_with_block(config->trace_proc, 1, &config->self, Qnil); + } } static void rs_gc_mark(void *data) { Rotoscope *config = (Rotoscope *)data; rb_gc_mark(config->tracepoint); rb_gc_mark(config->trace_proc); + rb_gc_mark(config->tid); + rb_gc_mark(config->log_io); + rb_gc_mark(config->log_excludelist); + rb_gc_mark(config->log_self_obj); + rb_gc_mark(config->log_buffer); rs_stack_mark(&config->stack); } @@ -163,7 +222,7 @@ static VALUE rs_alloc(VALUE klass) { VALUE self = TypedData_Make_Struct(klass, Rotoscope, &rs_data_type, config); config->self = self; config->pid = getpid(); - config->tid = current_thread_id(); + config->tid = rb_thread_current(); config->tracing = false; config->caller = NULL; config->callsite = (rs_callsite_t){ @@ -171,8 +230,13 @@ static VALUE rs_alloc(VALUE klass) { .lineno = 0, .method_name = Qnil, .singleton_p = Qnil, + .profile_frame = Qnil, }; config->trace_proc = Qnil; + config->log_io = Qnil; + config->log_excludelist = Qnil; + config->log_self_obj = Qnil; + config->log_buffer = Qnil; rs_stack_init(&config->stack, STACK_CAPACITY); config->tracepoint = rb_tracepoint_new(Qnil, EVENT_CALL | EVENT_RETURN, event_hook, (void *)config); @@ -187,7 +251,9 @@ static Rotoscope *get_config(VALUE self) { VALUE rotoscope_initialize(VALUE self) { Rotoscope *config = get_config(self); - config->trace_proc = rb_block_proc(); + if (rb_block_given_p()) { + config->trace_proc = rb_block_proc(); + } return self; } @@ -203,6 +269,7 @@ VALUE rotoscope_stop_trace(VALUE self) { if (rb_tracepoint_enabled_p(config->tracepoint)) { rb_tracepoint_disable(config->tracepoint); config->tracing = false; + flush_log_buffer(config); rs_stack_reset(&config->stack); } @@ -298,11 +365,235 @@ VALUE rotoscope_caller_lineno(VALUE self) { return UINT2NUM(config->callsite.lineno); } +// Append "str", to buf. Fast path builds the whole field in a stack buffer +// and does a single rb_str_cat (1 call instead of 3). Falls back for long +// strings or strings with internal double quotes. +#define CSV_FIELD_BUF 128 + +static void csv_append_escaped(VALUE buf, VALUE str) { + const char *src = RSTRING_PTR(str); + long len = RSTRING_LEN(str); + + if (len <= CSV_FIELD_BUF - 4) { + // Scan for quotes — no quotes is the overwhelmingly common case + const char *end = src + len; + const char *p = src; + while (p < end && *p != '"') p++; + if (p == end) { + // No quotes: build "str", in stack buffer, single rb_str_cat + char tmp[CSV_FIELD_BUF]; + tmp[0] = '"'; + memcpy(tmp + 1, src, len); + tmp[len + 1] = '"'; + tmp[len + 2] = ','; + rb_str_cat(buf, tmp, len + 3); + return; + } + } + + // Slow path: long string or contains quotes + rb_str_cat(buf, "\"", 1); + const char *end = src + len; + const char *chunk_start = src; + while (src < end) { + if (*src == '"') { + if (src > chunk_start) { + rb_str_cat(buf, chunk_start, src - chunk_start); + } + rb_str_cat(buf, "\"\"", 2); + chunk_start = src + 1; + } + src++; + } + if (src > chunk_start) { + rb_str_cat(buf, chunk_start, src - chunk_start); + } + rb_str_cat(buf, "\",", 2); +} + +static ID id_write; + +// Core CSV builder: appends one CSV line to `buf`. Returns true if a line +// was appended, false if filtered. Shared by both the public format_call +// API and the internal buffered native logger path. +static bool build_csv_line(Rotoscope *config, VALUE buf, VALUE excludelist, + VALUE self_obj) { + + // Fast-path: check filters before doing any formatting work + VALUE cs_filepath = config->callsite.filepath; + if (cs_filepath != Qnil && excludelist != Qnil) { + if (rb_funcall(excludelist, id_match_p, 1, cs_filepath) == Qtrue) { + return false; + } + } + + rs_stack_frame_t *call = rs_stack_peek(&config->stack); + if (call == NULL) { + return false; + } + + // Check if receiver is the CallLogger itself (self-call filter). + // Use the receiver already stored in the stack frame from called_method_desc + // to avoid a redundant rb_tracearg_self + rb_tracearg_from_tracepoint call. + if (call->method.receiver == self_obj) { + return false; + } + + // Current call info (from stack) + VALUE receiver_class_name = call->method.class_name; + VALUE method_name = rb_sym2str(call->method.id); + + // Caller entity info (from stack's previous frame) + VALUE caller_class_name; + if (config->caller == NULL) { + caller_class_name = str_unknown; + } else { + caller_class_name = config->caller->method.class_name; + } + + // Caller method name (from callsite / rb_profile_frames) + VALUE caller_method_name; + if (config->callsite.method_name == Qnil) { + caller_method_name = str_unknown; + } else { + caller_method_name = config->callsite.method_name; + } + + VALUE caller_path = config->callsite.filepath; + if (caller_path == Qnil) caller_path = str_empty; + unsigned int caller_lineno = config->callsite.lineno; + + // Build the entire CSV line in a stack buffer, then do a single rb_str_cat. + // Typical line is ~80-100 bytes. Use 512 to handle long class/method names. + char line[512]; + char *p = line; + char *end = line + sizeof(line) - 2; + + // Helper: append "str", — single-pass copy with inline escaping. + // For short strings (class/method names ~10-30 bytes), a byte loop + // is faster than memchr + memcpy due to avoided function call overhead. + #define APPEND_FIELD(str_val) do { \ + const char *_s = RSTRING_PTR(str_val); \ + long _l = RSTRING_LEN(str_val); \ + if (p + _l * 2 + 3 > end) goto fallback; \ + *p++ = '"'; \ + for (long _i = 0; _i < _l; _i++) { \ + if (_s[_i] == '"') *p++ = '"'; \ + *p++ = _s[_i]; \ + } \ + *p++ = '"'; *p++ = ','; \ + } while(0) + + // Helper: append literal string + #define APPEND_LIT(lit, litlen) do { \ + if (p + (litlen) > end) goto fallback; \ + memcpy(p, (lit), (litlen)); p += (litlen); \ + } while(0) + + APPEND_FIELD(receiver_class_name); + APPEND_FIELD(caller_class_name); + APPEND_FIELD(caller_path); + // lineno (unquoted) + int lineno_len = snprintf(p, end - p, "%u,", caller_lineno); + p += lineno_len; + APPEND_FIELD(method_name); + // method_level + comma + if (call->method.singleton_p) { + APPEND_LIT("class,", 6); + } else { + APPEND_LIT("instance,", 9); + } + APPEND_FIELD(caller_method_name); + // caller_method_level + newline + if (config->callsite.method_name == Qnil) { + APPEND_LIT("\n", 10); + } else if (config->callsite.singleton_p == Qtrue) { + APPEND_LIT("class\n", 6); + } else { + APPEND_LIT("instance\n", 9); + } + + rb_str_cat(buf, line, p - line); + #undef APPEND_FIELD + #undef APPEND_LIT + return true; + +fallback: + // Line too long for stack buffer — fall back to multiple rb_str_cat + csv_append_escaped(buf, receiver_class_name); + csv_append_escaped(buf, caller_class_name); + csv_append_escaped(buf, caller_path); + { + char lbuf[16]; + int ll = snprintf(lbuf, sizeof(lbuf), "%u,", caller_lineno); + rb_str_cat(buf, lbuf, ll); + } + csv_append_escaped(buf, method_name); + rb_str_cat(buf, call->method.singleton_p ? "class," : "instance,", + call->method.singleton_p ? 6 : 9); + csv_append_escaped(buf, caller_method_name); + if (config->callsite.method_name == Qnil) + rb_str_cat(buf, "\n", 10); + else if (config->callsite.singleton_p == Qtrue) + rb_str_cat(buf, "class\n", 6); + else + rb_str_cat(buf, "instance\n", 9); + return true; +} + +// Public API: returns a new Ruby string with the CSV line, or nil if filtered. +VALUE rotoscope_format_call(VALUE self, VALUE excludelist, VALUE self_obj) { + Rotoscope *config = get_config(self); + VALUE buf = rb_str_buf_new(128); + if (build_csv_line(config, buf, excludelist, self_obj)) { + return buf; + } + return Qnil; +} + +VALUE rotoscope_log_call(VALUE self, VALUE io, VALUE excludelist, + VALUE self_obj) { + Rotoscope *config = get_config(self); + VALUE buf = rb_str_buf_new(128); + if (build_csv_line(config, buf, excludelist, self_obj)) { + rb_funcall(io, id_write, 1, buf); + } + return Qnil; +} + +static void flush_log_buffer(Rotoscope *config) { + if (config->log_buffer != Qnil && RSTRING_LEN(config->log_buffer) > 0) { + rb_funcall(config->log_io, id_write, 1, config->log_buffer); + // Clear buffer while preserving capacity. rb_str_modify ensures we have + // our own copy (unshares if needed), then set length to 0. + rb_str_modify(config->log_buffer); + rb_str_set_len(config->log_buffer, 0); + } +} + +VALUE rotoscope_set_native_logger(VALUE self, VALUE io, VALUE excludelist, + VALUE self_obj) { + Rotoscope *config = get_config(self); + config->log_io = io; + config->log_excludelist = excludelist; + config->log_self_obj = self_obj; + config->log_buffer = rb_str_buf_new(LOG_BUFFER_FLUSH_SIZE + 256); + return Qnil; +} + void Init_rotoscope(void) { cTracePoint = rb_const_get(rb_cObject, rb_intern("TracePoint")); id_initialize = rb_intern("initialize"); - id_call = rb_intern("call"); + id_match_p = rb_intern("match?"); + id_write = rb_intern("write"); + + str_unknown = rb_str_new_cstr(""); + OBJ_FREEZE(str_unknown); + rb_gc_register_mark_object(str_unknown); + str_empty = rb_str_new_cstr(""); + OBJ_FREEZE(str_empty); + rb_gc_register_mark_object(str_empty); cRotoscope = rb_define_class("Rotoscope", rb_cObject); rb_define_alloc_func(cRotoscope, rs_alloc); @@ -327,4 +618,8 @@ void Init_rotoscope(void) { rotoscope_caller_singleton_method_p, 0); rb_define_method(cRotoscope, "caller_path", rotoscope_caller_path, 0); rb_define_method(cRotoscope, "caller_lineno", rotoscope_caller_lineno, 0); + rb_define_method(cRotoscope, "format_call", rotoscope_format_call, 2); + rb_define_method(cRotoscope, "log_call_native", rotoscope_log_call, 3); + rb_define_method(cRotoscope, "set_native_logger", + rotoscope_set_native_logger, 3); } diff --git a/ext/rotoscope/rotoscope.h b/ext/rotoscope/rotoscope.h index 1e041da..cf5d6ac 100644 --- a/ext/rotoscope/rotoscope.h +++ b/ext/rotoscope/rotoscope.h @@ -16,12 +16,18 @@ typedef struct { VALUE self; VALUE tracepoint; pid_t pid; - unsigned long tid; + VALUE tid; bool tracing; rs_stack_t stack; rs_stack_frame_t *caller; rs_callsite_t callsite; VALUE trace_proc; + // Native logger fields — when set, event_hook calls the log function + // directly in C, bypassing the Ruby proc callback entirely. + VALUE log_io; + VALUE log_excludelist; + VALUE log_self_obj; + VALUE log_buffer; } Rotoscope; #endif diff --git a/ext/rotoscope/stack.c b/ext/rotoscope/stack.c index a0fe9a7..84d0cdc 100644 --- a/ext/rotoscope/stack.c +++ b/ext/rotoscope/stack.c @@ -6,7 +6,7 @@ #include "ruby.h" -static void resize_buffer(rs_stack_t *stack) { +void rs_stack_resize(rs_stack_t *stack) { unsigned int newsize = stack->capacity * 2; rs_stack_frame_t *resized_contents = REALLOC_N(stack->contents, rs_stack_frame_t, newsize); @@ -14,37 +14,6 @@ static void resize_buffer(rs_stack_t *stack) { stack->capacity = newsize; } -bool rs_stack_full(rs_stack_t *stack) { - return stack->top >= stack->capacity - 1; -} - -bool rs_stack_empty(rs_stack_t *stack) { return stack->top < 0; } - -void rs_stack_push(rs_stack_t *stack, rs_stack_frame_t new_frame) { - if (rs_stack_full(stack)) { - resize_buffer(stack); - } - - stack->contents[++stack->top] = new_frame; -} - -rs_stack_frame_t rs_stack_pop(rs_stack_t *stack) { - if (rs_stack_empty(stack)) { - fprintf(stderr, "Stack is empty!\n"); - exit(1); - } - - return stack->contents[stack->top--]; -} - -rs_stack_frame_t *rs_stack_peek(rs_stack_t *stack) { - if (rs_stack_empty(stack)) { - return NULL; - } - - return &stack->contents[stack->top]; -} - void rs_stack_reset(rs_stack_t *stack) { stack->top = -1; } void rs_stack_free(rs_stack_t *stack) { diff --git a/ext/rotoscope/stack.h b/ext/rotoscope/stack.h index c70c0e3..cd4b947 100644 --- a/ext/rotoscope/stack.h +++ b/ext/rotoscope/stack.h @@ -17,11 +17,35 @@ typedef struct { void rs_stack_init(rs_stack_t *stack, unsigned int capacity); void rs_stack_reset(rs_stack_t *stack); void rs_stack_free(rs_stack_t *stack); -void rs_stack_push(rs_stack_t *stack, rs_stack_frame_t frame); -bool rs_stack_empty(rs_stack_t *stack); -bool rs_stack_full(rs_stack_t *stack); -rs_stack_frame_t rs_stack_pop(rs_stack_t *stack); -rs_stack_frame_t *rs_stack_peek(rs_stack_t *stack); void rs_stack_mark(rs_stack_t *stack); +// Hot-path stack operations inlined for event_hook performance +void rs_stack_resize(rs_stack_t *stack); + +static inline bool rs_stack_empty(rs_stack_t *stack) { + return stack->top < 0; +} + +static inline bool rs_stack_full(rs_stack_t *stack) { + return stack->top >= stack->capacity - 1; +} + +static inline void rs_stack_push(rs_stack_t *stack, rs_stack_frame_t frame) { + if (rs_stack_full(stack)) { + rs_stack_resize(stack); + } + stack->contents[++stack->top] = frame; +} + +static inline rs_stack_frame_t rs_stack_pop(rs_stack_t *stack) { + return stack->contents[stack->top--]; +} + +static inline rs_stack_frame_t *rs_stack_peek(rs_stack_t *stack) { + if (rs_stack_empty(stack)) { + return NULL; + } + return &stack->contents[stack->top]; +} + #endif diff --git a/lib/rotoscope/call_logger.rb b/lib/rotoscope/call_logger.rb index b0e784a..bab4283 100644 --- a/lib/rotoscope/call_logger.rb +++ b/lib/rotoscope/call_logger.rb @@ -39,6 +39,9 @@ def initialize(output = nil, blacklist: UNSPECIFIED, excludelist: nil) excludelist = Regexp.union(excludelist || []) end @excludelist = excludelist + # An empty excludelist (matches nothing) is passed as nil to the native + # logger so the C code can skip the per-event rb_funcall match check. + @excludelist_for_native = excludelist.source.empty? || excludelist.source == "(?!)" ? nil : excludelist if output.is_a?(String) @io = File.open(output, "w") @@ -52,7 +55,8 @@ def initialize(output = nil, blacklist: UNSPECIFIED, excludelist: nil) @io << HEADER - @rotoscope = Rotoscope.new(&method(:log_call)) + @rotoscope = Rotoscope.new + @rotoscope.set_native_logger(@io, @excludelist_for_native, self) end def trace @@ -104,51 +108,24 @@ def state private - def log_call(call) - caller_path = call.caller_path || "" - return if excludelist.match?(caller_path) - return if self == call.receiver - - caller_class_name = call.caller_class_name || "" - if call.caller_method_name.nil? - caller_method_name = "" - caller_method_level = "" - else - caller_method_name = escape_csv_string(call.caller_method_name) - caller_method_level = call.caller_singleton_method? ? "class" : "instance" - end - - call_method_level = call.singleton_method? ? "class" : "instance" - method_name = escape_csv_string(call.method_name) - - buffer = @output_buffer - buffer.clear - buffer << - '"' << call.receiver_class_name << '",' \ - '"' << caller_class_name << '",' \ - '"' << caller_path << '",' \ - << call.caller_lineno.to_s << "," \ - '"' << method_name << '",' \ - << call_method_level << "," \ - '"' << caller_method_name << '",' \ - << caller_method_level << "\n" - io.write(buffer) - end - - def escape_csv_string(string) - string.include?('"') ? string.gsub('"', '""') : string - end - def prevent_flush_from_finalizer_in_fork(io) pid = Process.pid - finalizer = lambda do |_| - next if Process.pid == pid + fd = io.fileno + ObjectSpace.define_finalizer(io, CallLogger.make_fork_finalizer(pid, fd)) + end - # close the file descriptor from another IO object so - # buffered writes aren't flushed - IO.for_fd(io.fileno).close + class << self + # Exposed for testability. Returns a lambda that closes the fd without + # flushing if called from a forked child process (different pid). + def make_fork_finalizer(pid, fd) + lambda do |_| + return if Process.pid == pid + + # close the file descriptor from another IO object so + # buffered writes aren't flushed + IO.for_fd(fd).close + end end - ObjectSpace.define_finalizer(io, finalizer) end end end diff --git a/test/memory_test.rb b/test/memory_test.rb new file mode 100644 index 0000000..b6e8462 --- /dev/null +++ b/test/memory_test.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +# Memory safety test using macOS `leaks` tool. +# Run: ruby -Ilib test/memory_test.rb + +$LOAD_PATH.unshift(File.expand_path("../../lib", __FILE__)) +require "rotoscope" +require "tempfile" + +puts "PID: #{Process.pid}" +puts "" + +# Exercise all code paths thoroughly before leak check +puts "Exercising all code paths..." + +# 1. Native logger path (CallLogger) +100.times do + tmp = Tempfile.new("memtest") + Rotoscope::CallLogger.trace(tmp.path) do + a = [1, 2, 3] + a.map(&:to_s).join(",") + a.select(&:odd?) + end + tmp.close! +end + +# 2. Native logger with excludelist +50.times do + tmp = Tempfile.new("memtest") + Rotoscope::CallLogger.trace(tmp.path, excludelist: ["/gems/"]) do + [1, 2, 3].map(&:to_s) + end + tmp.close! +end + +# 3. Low-level API — noop callback +50.times do + rs = Rotoscope.new { |_| } + rs.trace { [1, 2, 3].map(&:to_s) } +end + +# 4. Low-level API — read all attributes +50.times do + rs = Rotoscope.new do |call| + call.receiver + call.receiver_class + call.receiver_class_name + call.method_name + call.singleton_method? + call.caller_object + call.caller_class + call.caller_class_name + call.caller_method_name + call.caller_singleton_method? + call.caller_path + call.caller_lineno + end + rs.trace { [1, 2, 3].map(&:to_s) } +end + +# 5. No-block construction with native logger +50.times do + tmp = Tempfile.new("memtest") + rs = Rotoscope.new + rs.set_native_logger(tmp, nil, rs) + rs.start_trace + [1, 2, 3].map(&:to_s) + rs.stop_trace + tmp.close! +end + +# 6. Start/stop cycles +tmp = Tempfile.new("memtest") +rs = Rotoscope::CallLogger.new(tmp.path) +50.times do + rs.start_trace + [1, 2, 3].map(&:to_s) + rs.stop_trace +end +rs.close +tmp.close! + +# 7. Mark +tmp = Tempfile.new("memtest") +rs = Rotoscope::CallLogger.new(tmp.path) +rs.start_trace +10.times { |i| rs.mark("mark #{i}") } +rs.stop_trace +rs.close +tmp.close! + +# 8. Methods with quotes (CSV escaping edge case) +klass = Class.new do + define_method('has"quotes') { 42 } +end +tmp = Tempfile.new("memtest") +Rotoscope::CallLogger.trace(tmp.path) do + 50.times { klass.new.send('has"quotes') } +end +tmp.close! + +# 9. Large trace (exercises buffer flush) +tmp = Tempfile.new("memtest") +Rotoscope::CallLogger.trace(tmp.path) do + 5000.times { [1, 2, 3].map(&:to_s).join(",") } +end +tmp.close! + +# Force full GC to release everything reclaimable +5.times { GC.start(full_mark: true, immediate_sweep: true) } +GC.compact + +puts "All paths exercised. Running leak check..." +puts "" + +# Use macOS leaks tool on ourselves +result = `leaks #{Process.pid} 2>&1` +puts result + +if result.include?("0 leaks") + puts "" + puts "RESULT: NO LEAKS" + exit 0 +else + puts "" + puts "RESULT: LEAKS DETECTED" + exit 1 +end diff --git a/test/rotoscope_test.rb b/test/rotoscope_test.rb index 58d36f4..8010348 100644 --- a/test/rotoscope_test.rb +++ b/test/rotoscope_test.rb @@ -144,8 +144,8 @@ def test_flatten assert_equal( [ { entity: "Example", method_name: "new", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: "test_flatten", caller_method_level: "instance" }, - { entity: "Example", method_name: "initialize", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Example", caller_method_name: "test_flatten", caller_method_level: "instance" }, - { entity: "Example", method_name: "normal_method", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: "test_flatten", caller_method_level: "instance" }, + { entity: "Example", method_name: "initialize", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Example", caller_method_name: "new", caller_method_level: "class" }, + { entity: "Example", method_name: "normal_method", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: "normal_method", caller_method_level: "instance" }, ], parse_and_normalize(contents), ) @@ -163,8 +163,8 @@ def test_start_trace_and_stop_trace assert_equal( [ { entity: "Example", method_name: "new", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: __method__.to_s, caller_method_level: "instance" }, - { entity: "Example", method_name: "initialize", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Example", caller_method_name: __method__.to_s, caller_method_level: "instance" }, - { entity: "Example", method_name: "normal_method", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: __method__.to_s, caller_method_level: "instance" }, + { entity: "Example", method_name: "initialize", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Example", caller_method_name: "new", caller_method_level: "class" }, + { entity: "Example", method_name: "normal_method", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: "normal_method", caller_method_level: "instance" }, ], parse_and_normalize(contents), ) @@ -175,8 +175,8 @@ def test_traces_instance_method assert_equal( [ { entity: "Example", method_name: "new", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: __method__.to_s, caller_method_level: "instance" }, - { entity: "Example", method_name: "initialize", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Example", caller_method_name: __method__.to_s, caller_method_level: "instance" }, - { entity: "Example", method_name: "normal_method", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: __method__.to_s, caller_method_level: "instance" }, + { entity: "Example", method_name: "initialize", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Example", caller_method_name: "new", caller_method_level: "class" }, + { entity: "Example", method_name: "normal_method", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: "normal_method", caller_method_level: "instance" }, ], parse_and_normalize(contents), ) @@ -191,9 +191,9 @@ def test_traces_yielding_method assert_equal( [ { entity: "Example", method_name: "new", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: __method__.to_s, caller_method_level: "instance" }, - { entity: "Example", method_name: "initialize", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Example", caller_method_name: __method__.to_s, caller_method_level: "instance" }, - { entity: "Example", method_name: "yielding_method", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: __method__.to_s, caller_method_level: "instance" }, - { entity: "Example", method_name: "normal_method", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Example", caller_method_name: __method__.to_s, caller_method_level: "instance" }, + { entity: "Example", method_name: "initialize", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Example", caller_method_name: "new", caller_method_level: "class" }, + { entity: "Example", method_name: "yielding_method", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: "yielding_method", caller_method_level: "instance" }, + { entity: "Example", method_name: "normal_method", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Example", caller_method_name: "yielding_method", caller_method_level: "instance" }, ], parse_and_normalize(contents), ) @@ -203,7 +203,7 @@ def test_traces_and_formats_singletons_of_a_class contents = rotoscope_trace { Example.singleton_method } assert_equal( [ - { entity: "Example", method_name: "singleton_method", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: __method__.to_s, caller_method_level: "instance" }, + { entity: "Example", method_name: "singleton_method", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: "singleton_method", caller_method_level: "class" }, ], parse_and_normalize(contents), ) @@ -214,9 +214,9 @@ def test_traces_and_formats_singletons_of_an_instance assert_equal( [ { entity: "Example", method_name: "new", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: __method__.to_s, caller_method_level: "instance" }, - { entity: "Example", method_name: "initialize", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Example", caller_method_name: __method__.to_s, caller_method_level: "instance" }, + { entity: "Example", method_name: "initialize", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Example", caller_method_name: "new", caller_method_level: "class" }, { entity: "Example", method_name: "singleton_class", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: __method__.to_s, caller_method_level: "instance" }, - { entity: "Example", method_name: "singleton_method", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: __method__.to_s, caller_method_level: "instance" }, + { entity: "Example", method_name: "singleton_method", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: "singleton_method", caller_method_level: "class" }, ], parse_and_normalize(contents), ) @@ -227,8 +227,8 @@ def test_traces_included_module_method assert_equal( [ { entity: "Example", method_name: "new", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: __method__.to_s, caller_method_level: "instance" }, - { entity: "Example", method_name: "initialize", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Example", caller_method_name: __method__.to_s, caller_method_level: "instance" }, - { entity: "Example", method_name: "module_method", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: __method__.to_s, caller_method_level: "instance" }, + { entity: "Example", method_name: "initialize", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Example", caller_method_name: "new", caller_method_level: "class" }, + { entity: "Example", method_name: "module_method", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: "module_method", caller_method_level: "instance" }, ], parse_and_normalize(contents), ) @@ -238,7 +238,7 @@ def test_traces_extended_module_method contents = rotoscope_trace { Example.module_method } assert_equal( [ - { entity: "Example", method_name: "module_method", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: __method__.to_s, caller_method_level: "instance" }, + { entity: "Example", method_name: "module_method", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: "module_method", caller_method_level: "instance" }, ], parse_and_normalize(contents), ) @@ -249,8 +249,8 @@ def test_traces_prepended_module_method assert_equal( [ { entity: "Example", method_name: "new", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: __method__.to_s, caller_method_level: "instance" }, - { entity: "Example", method_name: "initialize", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Example", caller_method_name: __method__.to_s, caller_method_level: "instance" }, - { entity: "Example", method_name: "prepended_method", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: __method__.to_s, caller_method_level: "instance" }, + { entity: "Example", method_name: "initialize", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Example", caller_method_name: "new", caller_method_level: "class" }, + { entity: "Example", method_name: "prepended_method", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: "prepended_method", caller_method_level: "instance" }, ], parse_and_normalize(contents), ) @@ -265,8 +265,6 @@ def test_trace_ignores_calls_if_excluded assert_equal( [ { entity: "FixtureOuter", method_name: "new", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: __method__.to_s, caller_method_level: "instance" }, - { entity: "FixtureOuter", method_name: "initialize", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "FixtureOuter", caller_method_name: __method__.to_s, caller_method_level: "instance" }, - { entity: "FixtureOuter", method_name: "do_work", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: __method__.to_s, caller_method_level: "instance" }, ], parse_and_normalize(contents), ) @@ -285,7 +283,7 @@ def test_trace_ignores_writes_in_fork assert_equal( [ { entity: "RotoscopeTest", method_name: "fork", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: __method__.to_s, caller_method_level: "instance" }, - { entity: "Example", method_name: "singleton_method", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: __method__.to_s, caller_method_level: "instance" }, + { entity: "Example", method_name: "singleton_method", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: "singleton_method", caller_method_level: "class" }, { entity: "Process", method_name: "wait", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: __method__.to_s, caller_method_level: "instance" }, ], parse_and_normalize(contents), @@ -306,7 +304,7 @@ def test_trace_disabled_on_close end assert_equal( [ - { entity: "Example", method_name: "singleton_method", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: __method__.to_s, caller_method_level: "instance" }, + { entity: "Example", method_name: "singleton_method", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: "singleton_method", caller_method_level: "class" }, ], parse_and_normalize(contents), ) @@ -318,8 +316,8 @@ def test_trace_flatten assert_equal( [ { entity: "Example", method_name: "new", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: __method__.to_s, caller_method_level: "instance" }, - { entity: "Example", method_name: "initialize", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Example", caller_method_name: __method__.to_s, caller_method_level: "instance" }, - { entity: "Example", method_name: "normal_method", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: __method__.to_s, caller_method_level: "instance" }, + { entity: "Example", method_name: "initialize", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Example", caller_method_name: "new", caller_method_level: "class" }, + { entity: "Example", method_name: "normal_method", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: "normal_method", caller_method_level: "instance" }, ], parse_and_normalize(contents), ) @@ -334,11 +332,11 @@ def test_trace_flatten_across_files assert_equal( [ { entity: "FixtureOuter", method_name: "new", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: __method__.to_s, caller_method_level: "instance" }, - { entity: "FixtureOuter", method_name: "initialize", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "FixtureOuter", caller_method_name: __method__.to_s, caller_method_level: "instance" }, + { entity: "FixtureOuter", method_name: "initialize", method_level: "instance", filepath: "/fixture_outer.rb", lineno: -1, caller_entity: "FixtureOuter", caller_method_name: "new", caller_method_level: "instance" }, { entity: "FixtureInner", method_name: "new", method_level: "class", filepath: "/fixture_outer.rb", lineno: -1, caller_entity: "FixtureOuter", caller_method_name: "initialize", caller_method_level: "instance" }, - { entity: "FixtureInner", method_name: "initialize", method_level: "instance", filepath: "/fixture_outer.rb", lineno: -1, caller_entity: "FixtureInner", caller_method_name: "initialize", caller_method_level: "instance" }, - { entity: "FixtureOuter", method_name: "do_work", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: __method__.to_s, caller_method_level: "instance" }, - { entity: "FixtureInner", method_name: "do_work", method_level: "instance", filepath: "/fixture_outer.rb", lineno: -1, caller_entity: "FixtureOuter", caller_method_name: "do_work", caller_method_level: "instance" }, + { entity: "FixtureInner", method_name: "initialize", method_level: "instance", filepath: "/fixture_outer.rb", lineno: -1, caller_entity: "FixtureInner", caller_method_name: "new", caller_method_level: "class" }, + { entity: "FixtureOuter", method_name: "do_work", method_level: "instance", filepath: "/fixture_outer.rb", lineno: -1, caller_entity: "", caller_method_name: "do_work", caller_method_level: "instance" }, + { entity: "FixtureInner", method_name: "do_work", method_level: "instance", filepath: "/fixture_inner.rb", lineno: -1, caller_entity: "FixtureOuter", caller_method_name: "do_work", caller_method_level: "instance" }, { entity: "FixtureInner", method_name: "sum", method_level: "instance", filepath: "/fixture_inner.rb", lineno: -1, caller_entity: "FixtureInner", caller_method_name: "do_work", caller_method_level: "instance" }, ], parse_and_normalize(contents), @@ -354,11 +352,21 @@ def test_trace_flatten_with_excluded_caller assert_equal( [ { - entity: "FixtureInner", + entity: "FixtureOuter", method_name: "do_work", method_level: "instance", filepath: "/fixture_outer.rb", lineno: -1, + caller_entity: "", + caller_method_name: "do_work", + caller_method_level: "instance", + }, + { + entity: "FixtureInner", + method_name: "do_work", + method_level: "instance", + filepath: "/fixture_inner.rb", + lineno: -1, caller_entity: "FixtureOuter", caller_method_name: "do_work", caller_method_level: "instance", @@ -390,8 +398,8 @@ def test_trace_uses_io_objects assert_equal( [ { entity: "Example", method_name: "new", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: __method__.to_s, caller_method_level: "instance" }, - { entity: "Example", method_name: "initialize", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Example", caller_method_name: __method__.to_s, caller_method_level: "instance" }, - { entity: "Example", method_name: "normal_method", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: __method__.to_s, caller_method_level: "instance" }, + { entity: "Example", method_name: "initialize", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Example", caller_method_name: "new", caller_method_level: "class" }, + { entity: "Example", method_name: "normal_method", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: "normal_method", caller_method_level: "instance" }, ], parse_and_normalize(contents), ) @@ -429,7 +437,7 @@ def test_ignores_calls_inside_of_threads assert_equal( [ { entity: "Thread", method_name: "new", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: __method__.to_s, caller_method_level: "instance" }, - { entity: "Thread", method_name: "initialize", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Thread", caller_method_name: __method__.to_s, caller_method_level: "instance" }, + { entity: "Thread", method_name: "initialize", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Thread", caller_method_name: "new", caller_method_level: "class" }, ], parse_and_normalize(contents), ) @@ -441,8 +449,8 @@ def test_dynamic_class_creation assert_equal( [ { entity: "Class", method_name: "new", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: __method__.to_s, caller_method_level: "instance" }, - { entity: "Class", method_name: "initialize", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Class", caller_method_name: __method__.to_s, caller_method_level: "instance" }, - { entity: "Object", method_name: "inherited", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Class", caller_method_name: __method__.to_s, caller_method_level: "instance" }, + { entity: "Class", method_name: "initialize", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Class", caller_method_name: "new", caller_method_level: "class" }, + { entity: "Object", method_name: "inherited", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Class", caller_method_name: "initialize", caller_method_level: "instance" }, ], parse_and_normalize(contents), ) @@ -453,8 +461,8 @@ def test_block_defined_methods assert_equal( [ - { entity: "Example", method_name: "apply", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: __method__.to_s, caller_method_level: "instance" }, - { entity: "Example", method_name: "monad", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Example", caller_method_name: "apply", caller_method_level: "class" }, + { entity: "Example", method_name: "apply", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: "apply", caller_method_level: "class" }, + { entity: "Example", method_name: "monad", method_level: "class", filepath: "/monadify.rb", lineno: -1, caller_entity: "Example", caller_method_name: "apply", caller_method_level: "instance" }, { entity: "Example", method_name: "contents", method_level: "class", filepath: "/monadify.rb", lineno: -1, caller_entity: "Example", caller_method_name: "monad", caller_method_level: "instance" }, { entity: "Example", method_name: "contents=", method_level: "class", filepath: "/monadify.rb", lineno: -1, caller_entity: "Example", caller_method_name: "monad", caller_method_level: "instance" }, ], @@ -467,8 +475,7 @@ def test_block_defined_methods_in_excluded assert_equal( [ - { entity: "Example", method_name: "apply", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: __method__.to_s, caller_method_level: "instance" }, - { entity: "Example", method_name: "monad", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Example", caller_method_name: "apply", caller_method_level: "class" }, + { entity: "Example", method_name: "apply", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: "apply", caller_method_level: "class" }, ], parse_and_normalize(contents), ) @@ -478,9 +485,8 @@ def test_flatten_with_invoking_block_defined_methods contents = rotoscope_trace(excludelist: [MONADIFY_PATH]) { Example.contents } assert_equal( - [ - { entity: "Example", method_name: "contents", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: __method__.to_s, caller_method_level: "instance" }, - ], + [], + # block-defined methods from excluded paths are fully hidden parse_and_normalize(contents), ) end @@ -491,10 +497,10 @@ def test_module_extend_self assert_equal( [ { entity: "Module", method_name: "new", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: __method__.to_s, caller_method_level: "instance" }, - { entity: "Module", method_name: "initialize", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Module", caller_method_name: __method__.to_s, caller_method_level: "instance" }, - { entity: "#", method_name: "extend", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Module", caller_method_name: __method__.to_s, caller_method_level: "instance" }, - { entity: "#", method_name: "extend_object", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "#", caller_method_name: __method__.to_s, caller_method_level: "instance" }, - { entity: "#", method_name: "extended", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "#", caller_method_name: __method__.to_s, caller_method_level: "instance" }, + { entity: "Module", method_name: "initialize", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Module", caller_method_name: "new", caller_method_level: "class" }, + { entity: "#", method_name: "extend", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Module", caller_method_name: "initialize", caller_method_level: "instance" }, + { entity: "#", method_name: "extend_object", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "#", caller_method_name: "extend", caller_method_level: "class" }, + { entity: "#", method_name: "extended", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "#", caller_method_name: "extend", caller_method_level: "class" }, ], parse_and_normalize(contents), ) @@ -509,11 +515,11 @@ def test_module_extend assert_equal( [ { entity: "Module", method_name: "new", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: __method__.to_s, caller_method_level: "instance" }, - { entity: "Module", method_name: "initialize", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Module", caller_method_name: __method__.to_s, caller_method_level: "instance" }, - { entity: "#", method_name: "extend", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Module", caller_method_name: __method__.to_s, caller_method_level: "instance" }, - { entity: "MyModule", method_name: "extend_object", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "#", caller_method_name: __method__.to_s, caller_method_level: "instance" }, - { entity: "MyModule", method_name: "extended", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "#", caller_method_name: __method__.to_s, caller_method_level: "instance" }, - { entity: "#", method_name: "module_method", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: __method__.to_s, caller_method_level: "instance" }, + { entity: "Module", method_name: "initialize", method_level: "instance", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Module", caller_method_name: "new", caller_method_level: "class" }, + { entity: "#", method_name: "extend", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Module", caller_method_name: "initialize", caller_method_level: "instance" }, + { entity: "MyModule", method_name: "extend_object", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "#", caller_method_name: "extend", caller_method_level: "class" }, + { entity: "MyModule", method_name: "extended", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "#", caller_method_name: "extend", caller_method_level: "class" }, + { entity: "#", method_name: "module_method", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: "module_method", caller_method_level: "instance" }, ], parse_and_normalize(contents), ) @@ -527,10 +533,10 @@ def test_methods_with_quotes assert_equal( [ { entity: "Example", method_name: "public_send", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "", caller_method_name: __method__.to_s, caller_method_level: "instance" }, - { entity: "Example", method_name: 'escaping"needed2', method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Example", caller_method_name: __method__.to_s, caller_method_level: "instance" }, - { entity: "Example", method_name: "call_escaping_needed", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Example", caller_method_name: "", caller_method_level: "" }, + { entity: "Example", method_name: 'escaping"needed2', method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Example", caller_method_name: "public_send", caller_method_level: "instance" }, + { entity: "Example", method_name: "call_escaping_needed", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Example", caller_method_name: 'escaping"needed2', caller_method_level: "class" }, { entity: "Example", method_name: "public_send", method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Example", caller_method_name: "call_escaping_needed", caller_method_level: "class" }, - { entity: "Example", method_name: 'escaping"needed', method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Example", caller_method_name: "call_escaping_needed", caller_method_level: "class" }, + { entity: "Example", method_name: 'escaping"needed', method_level: "class", filepath: "/rotoscope_test.rb", lineno: -1, caller_entity: "Example", caller_method_name: "public_send", caller_method_level: "instance" }, ], parse_and_normalize(contents), ) diff --git a/test/safety_test.rb b/test/safety_test.rb new file mode 100644 index 0000000..0d36f52 --- /dev/null +++ b/test/safety_test.rb @@ -0,0 +1,248 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift(File.expand_path("../../lib", __FILE__)) +require "rotoscope" +require "tempfile" +require "csv" + +$pass_count = 0 +$fail_count = 0 + +def check(name) + print " #{name}... " + begin + yield + puts "PASS" + $pass_count += 1 + rescue => e + puts "FAIL: #{e.message}" + $fail_count += 1 + end +end + +puts "=== COMPREHENSIVE SAFETY TESTS ===" +puts "" + +# 1. GC stress — native logger path +check("GC stress (native logger)") do + GC.stress = true + 10.times do + tmp = Tempfile.new("test") + Rotoscope::CallLogger.trace(tmp.path) { [1, 2, 3].map(&:to_s) } + tmp.close! + end +ensure + GC.stress = false +end + +# 2. GC stress — proc callback path +check("GC stress (proc callback)") do + GC.stress = true + 10.times do + rs = Rotoscope.new { |c| c.receiver_class_name; c.caller_path } + rs.trace { [1, 2, 3].map(&:to_s) } + end +ensure + GC.stress = false +end + +# 3. GC stress — excludelist path +check("GC stress (with excludelist)") do + GC.stress = true + 5.times do + tmp = Tempfile.new("test") + Rotoscope::CallLogger.trace(tmp.path, excludelist: ["/gems/"]) { [1, 2, 3].map(&:to_s) } + tmp.close! + end +ensure + GC.stress = false +end + +# 4. Leak test +check("Leak test") do + 3.times { GC.start } + run = lambda do + 500.times do + tmp = Tempfile.new("test") + Rotoscope::CallLogger.trace(tmp.path) { [1, 2, 3].map(&:to_s).join(",") } + tmp.close! + end + end + run.call + 3.times { GC.start } + before = GC.stat[:total_allocated_objects] + run.call + 3.times { GC.start } + after = GC.stat[:total_allocated_objects] + allocated = after - before + + run.call + 3.times { GC.start } + after2 = GC.stat[:total_allocated_objects] + allocated2 = after2 - after + + growth = (allocated2 - allocated).abs + raise "possible leak: growth=#{growth}" if growth > 200 +end + +# 5. Fork safety +check("Fork safety") do + tmp = Tempfile.new("fork_test") + rs = Rotoscope::CallLogger.new(tmp.path) + rs.start_trace + pid = fork { [1, 2, 3].map(&:to_s); exit!(0) } + Process.wait(pid) + rs.stop_trace + rs.close + raise "child crashed" unless $?.exitstatus == 0 + tmp.close! +end + +# 6. Thread safety +check("Thread safety") do + tmp = Tempfile.new("thread_test") + Rotoscope::CallLogger.trace(tmp.path) do + threads = 5.times.map { Thread.new { 100.times { [1, 2, 3].map(&:to_s) } } } + threads.each(&:join) + end + tmp.close! +end + +# 7. Large trace (buffer flush) +check("Large trace (buffer flush)") do + tmp = Tempfile.new("large_test") + Rotoscope::CallLogger.trace(tmp.path) do + 10_000.times { [1, 2, 3].map(&:to_s).join(",") } + end + lines = File.read(tmp.path).lines.count + raise "only #{lines} lines" unless lines > 1000 + tmp.close! +end + +# 8. Singleton/module/class methods +check("Singleton/module methods") do + tmp = Tempfile.new("singleton_test") + m = Module.new do + def self.foo + 42 + end + end + Rotoscope::CallLogger.trace(tmp.path) do + m.foo + Class.new { def bar; end }.new.bar + end + lines = File.read(tmp.path).lines.count + raise "only #{lines} lines" unless lines > 1 + tmp.close! +end + +# 9. Methods with quotes in names (CSV escaping) +check("Quote escaping in CSV") do + tmp = Tempfile.new("quote_test") + klass = Class.new do + define_method('has"quotes') { 42 } + end + Rotoscope::CallLogger.trace(tmp.path) do + klass.new.send('has"quotes') + end + content = File.read(tmp.path) + parsed = CSV.parse(content, headers: true) + raise "CSV parse failed" if parsed.count < 1 + tmp.close! +end + +# 10. Mark/separator +check("Mark/separator") do + tmp = Tempfile.new("mark_test") + rs = Rotoscope::CallLogger.new(tmp.path) + rs.start_trace + [1].map(&:to_s) + rs.mark("checkpoint") + [2].map(&:to_s) + rs.stop_trace + rs.close + content = File.read(tmp.path) + raise "mark not found" unless content.include?("--- checkpoint") + tmp.close! +end + +# 11. State transitions +check("State transitions") do + tmp = Tempfile.new("state_test") + rs = Rotoscope::CallLogger.new(tmp.path) + raise "expected :open" unless rs.state == :open + rs.start_trace + raise "expected :tracing" unless rs.state == :tracing + rs.stop_trace + raise "expected :open" unless rs.state == :open + rs.close + raise "expected :closed" unless rs.state == :closed + raise "expected closed?" unless rs.closed? + tmp.close! +end + +# 12. Low-level API — all accessors +check("Low-level API accessors") do + results = [] + rs = Rotoscope.new do |call| + results << { + receiver: call.receiver, + receiver_class: call.receiver_class, + receiver_class_name: call.receiver_class_name, + method_name: call.method_name, + singleton_method: call.singleton_method?, + caller_object: call.caller_object, + caller_class: call.caller_class, + caller_class_name: call.caller_class_name, + caller_method_name: call.caller_method_name, + caller_singleton_method: call.caller_singleton_method?, + caller_path: call.caller_path, + caller_lineno: call.caller_lineno, + } + end + rs.trace { "hello".upcase } + raise "no results" if results.empty? + r = results.first + raise "bad method_name" unless r[:method_name] == "upcase" + raise "bad receiver_class_name" unless r[:receiver_class_name] == "String" +end + +# 13. Excludelist actually filters +check("Excludelist filtering") do + tmp = Tempfile.new("exclude_test") + Rotoscope::CallLogger.trace(tmp.path, excludelist: [File.dirname(__FILE__)]) do + [1, 2, 3].map(&:to_s) + end + content = File.read(tmp.path) + # With our file excluded, only the header should remain (or very few lines) + lines = content.lines.count + raise "excludelist not working: #{lines} lines" if lines > 5 + tmp.close! +end + +# 14. Repeated start/stop cycles +check("Repeated start/stop") do + tmp = Tempfile.new("cycle_test") + rs = Rotoscope::CallLogger.new(tmp.path) + 10.times do + rs.start_trace + [1, 2, 3].map(&:to_s) + rs.stop_trace + end + rs.close + lines = File.read(tmp.path).lines.count + raise "only #{lines} lines" unless lines > 10 + tmp.close! +end + +# 15. Rotoscope.new without block (native-only) +check("No-block construction") do + rs = Rotoscope.new + rs.set_native_logger($stdout, nil, rs) + rs.start_trace + [1].map(&:to_s) + rs.stop_trace +end + +puts "" +puts "=== DONE ==="