diff --git a/Makefile b/Makefile index 3a3e1b5da..b9dda6915 100644 --- a/Makefile +++ b/Makefile @@ -53,7 +53,7 @@ production_flags = $(warning_flags) -O3 -march=native -flto shared_library_flags = -fPIC # Default build mode (change this as needed) -flags = $(warning_flags) $(debug_flags) $(prism_flags) -std=c99 +flags = $(warning_flags) $(debug_flags) $(prism_flags) -std=gnu99 # Separate test compilation flags test_flags = $(debug_flags) $(prism_flags) -std=gnu99 diff --git a/config.yml b/config.yml index f60c9472d..693013f25 100644 --- a/config.yml +++ b/config.yml @@ -486,6 +486,16 @@ errors: - name: segment type: token + - name: TimeoutError + message: + template: "Parsing timed out after %zu ms." + arguments: + - timeout_ms + + fields: + - name: timeout_ms + type: size_t + warnings: fields: [] types: [] diff --git a/ext/herb/extension.c b/ext/herb/extension.c index 59d2f4b55..51a8b16cb 100644 --- a/ext/herb/extension.c +++ b/ext/herb/extension.c @@ -174,6 +174,10 @@ static VALUE Herb_parse(int argc, VALUE* argv, VALUE self) { if (NIL_P(html)) { html = rb_hash_lookup(options, ID2SYM(rb_intern("html"))); } if (!NIL_P(html) && !RTEST(html)) { parser_options.html = false; } + VALUE timeout = rb_hash_lookup(options, rb_utf8_str_new_cstr("timeout")); + if (NIL_P(timeout)) { timeout = rb_hash_lookup(options, ID2SYM(rb_intern("timeout"))); } + if (!NIL_P(timeout)) { parser_options.timeout_ms = (uint32_t) (NUM2DBL(timeout) * 1000); } + VALUE arena_stats = rb_hash_lookup(options, rb_utf8_str_new_cstr("arena_stats")); if (NIL_P(arena_stats)) { arena_stats = rb_hash_lookup(options, ID2SYM(rb_intern("arena_stats"))); } if (!NIL_P(arena_stats) && RTEST(arena_stats)) { print_arena_stats = true; } diff --git a/ext/herb/extension_helpers.c b/ext/herb/extension_helpers.c index 1d659fec9..8d741e7af 100644 --- a/ext/herb/extension_helpers.c +++ b/ext/herb/extension_helpers.c @@ -95,6 +95,7 @@ VALUE create_parse_result(AST_DOCUMENT_NODE_T* root, VALUE source, const parser_ rb_hash_aset(kwargs, ID2SYM(rb_intern("prism_nodes")), options->prism_nodes ? Qtrue : Qfalse); rb_hash_aset(kwargs, ID2SYM(rb_intern("prism_nodes_deep")), options->prism_nodes_deep ? Qtrue : Qfalse); rb_hash_aset(kwargs, ID2SYM(rb_intern("prism_program")), options->prism_program ? Qtrue : Qfalse); + rb_hash_aset(kwargs, ID2SYM(rb_intern("timeout")), DBL2NUM((double) options->timeout_ms / 1000.0)); VALUE parser_options_args[1] = { kwargs }; VALUE parser_options = rb_class_new_instance_kw(1, parser_options_args, cParserOptions, RB_PASS_KEYWORDS); diff --git a/java/Makefile b/java/Makefile index a9cc5dc71..06032dc94 100644 --- a/java/Makefile +++ b/java/Makefile @@ -30,7 +30,7 @@ PRISM_BUILD = $(PRISM_PATH)/build JAVAC = $(JAVA_HOME)/bin/javac JAVA_CMD = $(JAVA_HOME)/bin/java -CFLAGS = -std=c99 -Wall -Wextra -fPIC -O2 -DHERB_EXCLUDE_PRETTYPRINT -DPRISM_EXCLUDE_PRETTYPRINT -DPRISM_EXCLUDE_JSON -DPRISM_EXCLUDE_PACK +CFLAGS = -std=gnu99 -Wall -Wextra -fPIC -O2 -DHERB_EXCLUDE_PRETTYPRINT -DPRISM_EXCLUDE_PRETTYPRINT -DPRISM_EXCLUDE_JSON -DPRISM_EXCLUDE_PACK INCLUDES = -I. -I$(SRC_DIR)/include -I$(PRISM_INCLUDE) $(JNI_INCLUDES) LDFLAGS = -shared LIBS = $(PRISM_BUILD)/libprism.a diff --git a/java/herb_jni.c b/java/herb_jni.c index 495e5185e..e73ad410f 100644 --- a/java/herb_jni.c +++ b/java/herb_jni.c @@ -134,6 +134,14 @@ Java_org_herb_Herb_parse(JNIEnv* env, jclass clazz, jstring source, jobject opti jboolean html = (*env)->CallBooleanMethod(env, options, getHtml); parser_options.html = (html == JNI_TRUE); } + + jmethodID getTimeout = + (*env)->GetMethodID(env, optionsClass, "getTimeout", "()I"); + + if (getTimeout != NULL) { + jint timeout = (*env)->CallIntMethod(env, options, getTimeout); + parser_options.timeout_ms = (uint32_t) timeout; + } } hb_allocator_T allocator; diff --git a/java/org/herb/ParserOptions.java b/java/org/herb/ParserOptions.java index 4284f0faf..7494ed5e8 100644 --- a/java/org/herb/ParserOptions.java +++ b/java/org/herb/ParserOptions.java @@ -13,6 +13,7 @@ public class ParserOptions { private boolean prismProgram = false; private boolean dotNotationTags = false; private boolean html = true; + private int timeout = 1000; public ParserOptions() {} @@ -124,6 +125,15 @@ public boolean isHtml() { return html; } + public ParserOptions timeout(int value) { + this.timeout = value; + return this; + } + + public int getTimeout() { + return timeout; + } + public static ParserOptions create() { return new ParserOptions(); } diff --git a/javascript/packages/core/src/parser-options.ts b/javascript/packages/core/src/parser-options.ts index 68958ac9c..5eab21b8a 100644 --- a/javascript/packages/core/src/parser-options.ts +++ b/javascript/packages/core/src/parser-options.ts @@ -11,6 +11,7 @@ export interface ParseOptions { prism_program?: boolean dot_notation_tags?: boolean html?: boolean + timeout?: number } export type SerializedParserOptions = Required @@ -28,6 +29,7 @@ export const DEFAULT_PARSER_OPTIONS: SerializedParserOptions = { prism_program: false, dot_notation_tags: false, html: true, + timeout: 1000, } /** @@ -70,6 +72,9 @@ export class ParserOptions { /** Whether HTML tag parsing is enabled during parsing. When false, HTML-like content is treated as literal text. */ readonly html: boolean + /** Parse timeout in milliseconds. 0 disables the timeout. */ + readonly timeout: number + static from(options: SerializedParserOptions): ParserOptions { return new ParserOptions(options) } @@ -87,5 +92,6 @@ export class ParserOptions { this.prism_program = options.prism_program ?? DEFAULT_PARSER_OPTIONS.prism_program this.dot_notation_tags = options.dot_notation_tags ?? DEFAULT_PARSER_OPTIONS.dot_notation_tags this.html = options.html ?? DEFAULT_PARSER_OPTIONS.html + this.timeout = options.timeout ?? DEFAULT_PARSER_OPTIONS.timeout } } diff --git a/javascript/packages/linter/test/parse-cache.test.ts b/javascript/packages/linter/test/parse-cache.test.ts index 98db6e6f4..4bcad34b5 100644 --- a/javascript/packages/linter/test/parse-cache.test.ts +++ b/javascript/packages/linter/test/parse-cache.test.ts @@ -78,6 +78,7 @@ describe("ParseCache", () => { dot_notation_tags: false, transform_conditionals: false, html: true, + timeout: 1000, }) }) @@ -98,6 +99,7 @@ describe("ParseCache", () => { dot_notation_tags: false, transform_conditionals: false, html: true, + timeout: 1000, }) }) @@ -118,6 +120,7 @@ describe("ParseCache", () => { dot_notation_tags: false, transform_conditionals: false, html: true, + timeout: 1000, }) }) @@ -138,6 +141,7 @@ describe("ParseCache", () => { dot_notation_tags: false, transform_conditionals: false, html: true, + timeout: 1000, }) }) }) diff --git a/lib/herb/parser_options.rb b/lib/herb/parser_options.rb index 5cfd3b2e5..aeda32bb6 100644 --- a/lib/herb/parser_options.rb +++ b/lib/herb/parser_options.rb @@ -12,6 +12,7 @@ class ParserOptions attr_reader :prism_program #: bool attr_reader :prism_nodes #: bool attr_reader :prism_nodes_deep #: bool + attr_reader :timeout #: Numeric DEFAULT_STRICT = true #: bool DEFAULT_TRACK_WHITESPACE = false #: bool @@ -23,9 +24,10 @@ class ParserOptions DEFAULT_PRISM_PROGRAM = false #: bool DEFAULT_PRISM_NODES = false #: bool DEFAULT_PRISM_NODES_DEEP = false #: bool + DEFAULT_TIMEOUT = 1 #: Numeric - #: (?strict: bool, ?track_whitespace: bool, ?analyze: bool, ?action_view_helpers: bool, ?transform_conditionals: bool, ?render_nodes: bool, ?strict_locals: bool, ?prism_nodes: bool, ?prism_nodes_deep: bool, ?prism_program: bool) -> void - def initialize(strict: DEFAULT_STRICT, track_whitespace: DEFAULT_TRACK_WHITESPACE, analyze: DEFAULT_ANALYZE, action_view_helpers: DEFAULT_ACTION_VIEW_HELPERS, transform_conditionals: DEFAULT_TRANSFORM_CONDITIONALS, render_nodes: DEFAULT_RENDER_NODES, strict_locals: DEFAULT_STRICT_LOCALS, prism_nodes: DEFAULT_PRISM_NODES, prism_nodes_deep: DEFAULT_PRISM_NODES_DEEP, prism_program: DEFAULT_PRISM_PROGRAM) + #: (?strict: bool, ?track_whitespace: bool, ?analyze: bool, ?action_view_helpers: bool, ?transform_conditionals: bool, ?render_nodes: bool, ?strict_locals: bool, ?prism_nodes: bool, ?prism_nodes_deep: bool, ?prism_program: bool, ?timeout: Numeric) -> void + def initialize(strict: DEFAULT_STRICT, track_whitespace: DEFAULT_TRACK_WHITESPACE, analyze: DEFAULT_ANALYZE, action_view_helpers: DEFAULT_ACTION_VIEW_HELPERS, transform_conditionals: DEFAULT_TRANSFORM_CONDITIONALS, render_nodes: DEFAULT_RENDER_NODES, strict_locals: DEFAULT_STRICT_LOCALS, prism_nodes: DEFAULT_PRISM_NODES, prism_nodes_deep: DEFAULT_PRISM_NODES_DEEP, prism_program: DEFAULT_PRISM_PROGRAM, timeout: DEFAULT_TIMEOUT) @strict = strict @track_whitespace = track_whitespace @analyze = analyze @@ -36,9 +38,10 @@ def initialize(strict: DEFAULT_STRICT, track_whitespace: DEFAULT_TRACK_WHITESPAC @prism_nodes = prism_nodes @prism_nodes_deep = prism_nodes_deep @prism_program = prism_program + @timeout = timeout end - #: () -> Hash[Symbol, bool] + #: () -> Hash[Symbol, (bool | Numeric)] def to_h { strict: @strict, @@ -51,6 +54,7 @@ def to_h prism_nodes: @prism_nodes, prism_nodes_deep: @prism_nodes_deep, prism_program: @prism_program, + timeout: @timeout, } end @@ -66,7 +70,8 @@ def inspect "strict_locals=#{@strict_locals}\n " \ "prism_nodes=#{@prism_nodes}\n " \ "prism_nodes_deep=#{@prism_nodes_deep}\n " \ - "prism_program=#{@prism_program}>" + "prism_program=#{@prism_program}\n " \ + "timeout=#{@timeout}>" end end end diff --git a/rust/build.rs b/rust/build.rs index 3ae410308..de529bacc 100644 --- a/rust/build.rs +++ b/rust/build.rs @@ -70,7 +70,7 @@ fn main() { let mut build = cc::Build::new(); build - .flag("-std=c99") + .flag("-std=gnu99") .flag("-Wall") .flag("-Wextra") .flag("-Wno-unused-parameter") diff --git a/rust/src/herb.rs b/rust/src/herb.rs index 34c2003fb..1eba5197d 100644 --- a/rust/src/herb.rs +++ b/rust/src/herb.rs @@ -17,6 +17,7 @@ pub struct ParserOptions { pub prism_program: bool, pub dot_notation_tags: bool, pub html: bool, + pub timeout: u32, } impl Default for ParserOptions { @@ -34,6 +35,7 @@ impl Default for ParserOptions { prism_program: false, dot_notation_tags: false, html: true, + timeout: 1000, } } } @@ -120,6 +122,8 @@ pub fn parse_with_options(source: &str, options: &ParserOptions) -> Result Result { html: true, start_line: 0, start_column: 0, + timeout_ms: 1000, + deadline_ms: 0, }; let old_root = crate::ffi::herb_parse(old_c_source.as_ptr(), &parser_options, &mut old_allocator); diff --git a/sig/herb/errors.rbs b/sig/herb/errors.rbs index f03d2cbfe..c38f255aa 100644 --- a/sig/herb/errors.rbs +++ b/sig/herb/errors.rbs @@ -694,5 +694,23 @@ module Herb # : (?indent: Integer, ?depth: Integer, ?depth_limit: Integer) -> String def tree_inspect: (?indent: Integer, ?depth: Integer, ?depth_limit: Integer) -> String end + + class TimeoutError < Error + include Colors + + attr_reader timeout_ms: Integer? + + # : (String, Location?, String, Integer) -> void + def initialize: (String, Location?, String, Integer) -> void + + # : () -> String + def inspect: () -> String + + # : () -> serialized_timeout_error + def to_hash: () -> serialized_timeout_error + + # : (?indent: Integer, ?depth: Integer, ?depth_limit: Integer) -> String + def tree_inspect: (?indent: Integer, ?depth: Integer, ?depth_limit: Integer) -> String + end end end diff --git a/sig/herb/parser_options.rbs b/sig/herb/parser_options.rbs index d25a408f0..0726497d7 100644 --- a/sig/herb/parser_options.rbs +++ b/sig/herb/parser_options.rbs @@ -22,6 +22,8 @@ module Herb attr_reader prism_nodes_deep: bool + attr_reader timeout: Numeric + DEFAULT_STRICT: bool DEFAULT_TRACK_WHITESPACE: bool @@ -42,11 +44,13 @@ module Herb DEFAULT_PRISM_NODES_DEEP: bool - # : (?strict: bool, ?track_whitespace: bool, ?analyze: bool, ?action_view_helpers: bool, ?transform_conditionals: bool, ?render_nodes: bool, ?strict_locals: bool, ?prism_nodes: bool, ?prism_nodes_deep: bool, ?prism_program: bool) -> void - def initialize: (?strict: bool, ?track_whitespace: bool, ?analyze: bool, ?action_view_helpers: bool, ?transform_conditionals: bool, ?render_nodes: bool, ?strict_locals: bool, ?prism_nodes: bool, ?prism_nodes_deep: bool, ?prism_program: bool) -> void + DEFAULT_TIMEOUT: Numeric + + # : (?strict: bool, ?track_whitespace: bool, ?analyze: bool, ?action_view_helpers: bool, ?transform_conditionals: bool, ?render_nodes: bool, ?strict_locals: bool, ?prism_nodes: bool, ?prism_nodes_deep: bool, ?prism_program: bool, ?timeout: Numeric) -> void + def initialize: (?strict: bool, ?track_whitespace: bool, ?analyze: bool, ?action_view_helpers: bool, ?transform_conditionals: bool, ?render_nodes: bool, ?strict_locals: bool, ?prism_nodes: bool, ?prism_nodes_deep: bool, ?prism_program: bool, ?timeout: Numeric) -> void - # : () -> Hash[Symbol, bool] - def to_h: () -> Hash[Symbol, bool] + # : () -> Hash[Symbol, (bool | Numeric)] + def to_h: () -> Hash[Symbol, bool | Numeric] # : () -> String def inspect: () -> String diff --git a/src/herb.c b/src/herb.c index 8a455d5ae..4b68d8efd 100644 --- a/src/herb.c +++ b/src/herb.c @@ -1,6 +1,7 @@ #include "include/herb.h" #include "include/analyze/analyze.h" #include "include/analyze/prism_annotate.h" +#include "include/errors.h" #include "include/lexer/lexer.h" #include "include/lexer/token.h" #include "include/lib/hb_allocator.h" @@ -44,6 +45,8 @@ HERB_EXPORTED_FUNCTION AST_DOCUMENT_NODE_T* herb_parse( parser_options_T parser_options = HERB_DEFAULT_PARSER_OPTIONS; if (options != NULL) { parser_options = *options; } + parser_options_set_deadline(&parser_options); + if (parser_options.start_line > 0) { lexer.current_line = parser_options.start_line; lexer.previous_line = parser_options.start_line; @@ -73,6 +76,16 @@ HERB_EXPORTED_FUNCTION AST_DOCUMENT_NODE_T* herb_parse( ); } + if (parser_options_past_deadline(&parser_options)) { + append_timeout_error( + parser_options.timeout_ms, + document->base.location.start, + document->base.location.end, + allocator, + document->base.errors + ); + } + return document; } diff --git a/src/include/lib/hb_clock.h b/src/include/lib/hb_clock.h new file mode 100644 index 000000000..45076860d --- /dev/null +++ b/src/include/lib/hb_clock.h @@ -0,0 +1,15 @@ +#ifndef HERB_CLOCK_H +#define HERB_CLOCK_H + +#include +#include + +static inline uint64_t hb_monotonic_ms(void) { + struct timespec now; + + clock_gettime(CLOCK_MONOTONIC, &now); + + return (uint64_t) now.tv_sec * 1000 + (uint64_t) now.tv_nsec / 1000000; +} + +#endif diff --git a/src/include/parser/parser.h b/src/include/parser/parser.h index 513b7d2fd..a58a585b2 100644 --- a/src/include/parser/parser.h +++ b/src/include/parser/parser.h @@ -5,6 +5,7 @@ #include "../lexer/lexer.h" #include "../lib/hb_allocator.h" #include "../lib/hb_array.h" +#include "../lib/hb_clock.h" #include @@ -33,6 +34,8 @@ typedef struct PARSER_OPTIONS_STRUCT { bool html; uint32_t start_line; uint32_t start_column; + uint32_t timeout_ms; + uint64_t deadline_ms; } parser_options_T; typedef struct MATCH_TAGS_CONTEXT_STRUCT { @@ -43,6 +46,18 @@ typedef struct MATCH_TAGS_CONTEXT_STRUCT { extern const parser_options_T HERB_DEFAULT_PARSER_OPTIONS; +static inline bool parser_options_past_deadline(const parser_options_T* options) { + if (options == NULL || options->timeout_ms == 0) { return false; } + + return hb_monotonic_ms() >= options->deadline_ms; +} + +static inline void parser_options_set_deadline(parser_options_T* options) { + if (options->timeout_ms == 0) { return; } + + options->deadline_ms = hb_monotonic_ms() + options->timeout_ms; +} + typedef struct PARSER_STRUCT { hb_allocator_T* allocator; lexer_T* lexer; diff --git a/src/parser.c b/src/parser.c index e32483a8b..d081eed13 100644 --- a/src/parser.c +++ b/src/parser.c @@ -23,6 +23,7 @@ #include #define MAX_CONSECUTIVE_ERRORS 10 +#define DEADLINE_CHECK_INTERVAL 1024 static void parser_parse_in_data_state(parser_T* parser, hb_array_T* children, hb_array_T* errors); static void parser_parse_foreign_content(parser_T* parser, hb_array_T* children, hb_array_T* errors); @@ -48,7 +49,9 @@ const parser_options_T HERB_DEFAULT_PARSER_OPTIONS = { .track_whitespace = false .dot_notation_tags = false, .html = true, .start_line = 0, - .start_column = 0 }; + .start_column = 0, + .timeout_ms = 1000, + .deadline_ms = 0 }; size_t parser_sizeof(void) { return sizeof(struct PARSER_STRUCT); @@ -1498,6 +1501,7 @@ static void parser_parse_in_data_state(parser_T* parser, hb_array_T* children, h } while (token_is_not(parser, TOKEN_EOF)) { + if (parser_options_past_deadline(&parser->options)) { break; } if (token_is(parser, TOKEN_ERB_START)) { hb_array_append(children, parser_parse_erb_tag(parser)); @@ -1601,11 +1605,18 @@ static void parser_parse_in_data_state(parser_T* parser, hb_array_T* children, h } } -static size_t find_matching_close_tag(hb_array_T* nodes, size_t start_idx, hb_string_T tag_name) { +static size_t find_matching_close_tag( + hb_array_T* nodes, + size_t start_index, + hb_string_T tag_name, + const parser_options_T* options +) { int depth = 0; - for (size_t i = start_idx + 1; i < hb_array_size(nodes); i++) { - AST_NODE_T* node = (AST_NODE_T*) hb_array_get(nodes, i); + for (size_t index = start_index + 1; index < hb_array_size(nodes); index++) { + if ((index & (DEADLINE_CHECK_INTERVAL - 1)) == 0 && parser_options_past_deadline(options)) { return (size_t) -1; } + + AST_NODE_T* node = (AST_NODE_T*) hb_array_get(nodes, index); if (node == NULL) { continue; } if (node->type == AST_HTML_OPEN_TAG_NODE) { @@ -1616,7 +1627,7 @@ static size_t find_matching_close_tag(hb_array_T* nodes, size_t start_idx, hb_st AST_HTML_CLOSE_TAG_NODE_T* close = (AST_HTML_CLOSE_TAG_NODE_T*) node; if (hb_string_equals_case_insensitive(close->tag_name->value, tag_name)) { - if (depth == 0) { return i; } + if (depth == 0) { return index; } depth--; } } @@ -1625,23 +1636,23 @@ static size_t find_matching_close_tag(hb_array_T* nodes, size_t start_idx, hb_st return (size_t) -1; } -static size_t find_implicit_close_index(hb_array_T* nodes, size_t start_idx, hb_string_T tag_name) { +static size_t find_implicit_close_index(hb_array_T* nodes, size_t start_index, hb_string_T tag_name) { if (!has_optional_end_tag(tag_name)) { return (size_t) -1; } - for (size_t i = start_idx + 1; i < hb_array_size(nodes); i++) { - AST_NODE_T* node = (AST_NODE_T*) hb_array_get(nodes, i); + for (size_t index = start_index + 1; index < hb_array_size(nodes); index++) { + AST_NODE_T* node = (AST_NODE_T*) hb_array_get(nodes, index); if (node == NULL) { continue; } if (node->type == AST_HTML_OPEN_TAG_NODE) { AST_HTML_OPEN_TAG_NODE_T* open = (AST_HTML_OPEN_TAG_NODE_T*) node; hb_string_T next_tag_name = open->tag_name->value; - if (should_implicitly_close(tag_name, next_tag_name)) { return i; } + if (should_implicitly_close(tag_name, next_tag_name)) { return index; } } else if (node->type == AST_HTML_CLOSE_TAG_NODE) { AST_HTML_CLOSE_TAG_NODE_T* close = (AST_HTML_CLOSE_TAG_NODE_T*) node; hb_string_T close_tag_name = close->tag_name->value; - if (parent_closes_element(tag_name, close_tag_name)) { return i; } + if (parent_closes_element(tag_name, close_tag_name)) { return index; } } } @@ -1695,6 +1706,8 @@ static hb_array_T* parser_build_elements_from_tags( hb_array_T* close_tag_names = collect_close_tag_names(nodes, allocator); for (size_t index = 0; index < hb_array_size(nodes); index++) { + if ((index & (DEADLINE_CHECK_INTERVAL - 1)) == 0 && parser_options_past_deadline(options)) { break; } + AST_NODE_T* node = (AST_NODE_T*) hb_array_get(nodes, index); if (node == NULL) { continue; } @@ -1705,7 +1718,7 @@ static hb_array_T* parser_build_elements_from_tags( size_t close_index = (size_t) -1; if (has_close_tag_for_name(close_tag_names, tag_name)) { - close_index = find_matching_close_tag(nodes, index, tag_name); + close_index = find_matching_close_tag(nodes, index, tag_name, options); } if (close_index == (size_t) -1) { diff --git a/test/parser/performance_test.rb b/test/parser/performance_test.rb index 384ddb266..44bb599a8 100644 --- a/test/parser/performance_test.rb +++ b/test/parser/performance_test.rb @@ -4,35 +4,45 @@ module Parser class PerformanceTest < Minitest::Spec - test "many unclosed tags parses without quadratic slowdown" do + test "many unclosed tags parses within timeout" do source = "
" * 100_000 - start = Process.clock_gettime(Process::CLOCK_MONOTONIC) - result = Herb.parse(source) - elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start + result = Herb.parse(source, timeout: 1) assert_instance_of Herb::AST::DocumentNode, result.value - assert elapsed < 5, "Parsing 100,000 unclosed tags took #{elapsed.round(2)}s, expected < 5s" + + timeout_errors = result.errors.select { |e| e.is_a?(Herb::Errors::TimeoutError) } + assert_empty timeout_errors end - test "many matched tags parses quickly" do + test "many matched tags parses within timeout" do source = "
x
" * 100_000 - start = Process.clock_gettime(Process::CLOCK_MONOTONIC) - result = Herb.parse(source) - elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start + result = Herb.parse(source, timeout: 1) assert_instance_of Herb::AST::DocumentNode, result.value - assert elapsed < 5, "Parsing 100,000 matched tags took #{elapsed.round(2)}s, expected < 5s" + + timeout_errors = result.errors.select { |e| e.is_a?(Herb::Errors::TimeoutError) } + assert_empty timeout_errors end - test "many unclosed tags of different names parses quickly" do + test "many unclosed tags of different names parses within timeout" do tags = ["div", "span", "p", "a", "section", "article", "header", "footer", "main", "nav"].cycle.take(100_000) source = tags.map { |t| "<#{t}>" }.join - start = Process.clock_gettime(Process::CLOCK_MONOTONIC) - result = Herb.parse(source) - elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start + result = Herb.parse(source, timeout: 1) assert_instance_of Herb::AST::DocumentNode, result.value - assert elapsed < 5, "Parsing 100,000 mixed unclosed tags took #{elapsed.round(2)}s, expected < 5s" + + timeout_errors = result.errors.select { |e| e.is_a?(Herb::Errors::TimeoutError) } + assert_empty timeout_errors + end + + test "tight timeout produces TimeoutError" do + source = "
" * 100_000 + result = Herb.parse(source, timeout: 0.001) + + assert_instance_of Herb::AST::DocumentNode, result.value + + timeout_errors = result.errors.select { |e| e.is_a?(Herb::Errors::TimeoutError) } + refute_empty timeout_errors end end end diff --git a/wasm/herb-wasm.cpp b/wasm/herb-wasm.cpp index 7f42f7a7b..b7b44860a 100644 --- a/wasm/herb-wasm.cpp +++ b/wasm/herb-wasm.cpp @@ -99,6 +99,10 @@ val Herb_parse(const std::string& source, val options) { if (options.hasOwnProperty("html")) { parser_options.html = options["html"].as(); } + + if (options.hasOwnProperty("timeout")) { + parser_options.timeout_ms = (uint32_t) options["timeout"].as(); + } } hb_allocator_T allocator;