From 70c46e081dcfe4433636ee8510b622158574881a Mon Sep 17 00:00:00 2001 From: InvalidUsernameException Date: Sat, 11 Oct 2025 22:38:35 +0200 Subject: [PATCH 01/27] LibWeb: Correctly calculate nested positioned elements' static position If there are multiple nested `position: fixed` or `position: absolute` elements that are positioned based on their static position due to not specifying any insets, we sum up all their ancestor offsets to calculate said static position. However, these offsets represent the offset to the containing block. So summing up all the ancestor blocks will count elements multiple times for cases where the containing block is not based on the closest element capable of forming a containing block (i.e. absolute and fixed position elements) when multiple such elements are nested. With this change we only iterate over ancestors forming containing blocks instead of over all ancestors boxes. To sum up everything between the box currently being positioned and its containing block, we start the iteration on the parent box of the current box. This fixes 3 WPT tests that I could find. But these tests are not intended to test the error cases fixed here, they just incidentally rely on the correct behavior. As such, I have added dedicated tests myself. Note that two of the tests already pass on master, but they seemed like a good cases to have anyway. --- Libraries/LibWeb/Layout/FormattingContext.cpp | 6 ++++-- ...absolute-fixed-nested-static-position.html | 9 ++++++++ ...lute-and-fixed-nested-static-position.html | 21 +++++++++++++++++++ ...ition-absolute-nested-static-position.html | 20 ++++++++++++++++++ ...d-and-absolute-nested-static-position.html | 21 +++++++++++++++++++ ...position-fixed-nested-static-position.html | 20 ++++++++++++++++++ 6 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 Tests/LibWeb/Ref/expected/position-absolute-fixed-nested-static-position.html create mode 100644 Tests/LibWeb/Ref/input/position-absolute-and-fixed-nested-static-position.html create mode 100644 Tests/LibWeb/Ref/input/position-absolute-nested-static-position.html create mode 100644 Tests/LibWeb/Ref/input/position-fixed-and-absolute-nested-static-position.html create mode 100644 Tests/LibWeb/Ref/input/position-fixed-nested-static-position.html diff --git a/Libraries/LibWeb/Layout/FormattingContext.cpp b/Libraries/LibWeb/Layout/FormattingContext.cpp index 0f2ad4eb694..1e5377babc0 100644 --- a/Libraries/LibWeb/Layout/FormattingContext.cpp +++ b/Libraries/LibWeb/Layout/FormattingContext.cpp @@ -1167,13 +1167,15 @@ CSSPixelRect FormattingContext::content_box_rect_in_static_position_ancestor_coo { auto box_used_values = m_state.get(box); CSSPixelRect rect = { { 0, 0 }, box_used_values.content_size() }; - for (auto const* current = &box; current; current = current->static_position_containing_block()) { + VERIFY(box_used_values.offset.is_zero()); // Set as result of this calculation + for (auto const* current = box.static_position_containing_block(); current; current = current->containing_block()) { if (current == &ancestor_box) return rect; auto const& current_state = m_state.get(*current); rect.translate_by(current_state.offset); } - // If we get here, ancestor_box was not an ancestor of `box`! + // If we get here, `ancestor_box` was not in the containing block chain of the static position containing block of `box`! + // Something about the containing block chain is set up incorrectly then. VERIFY_NOT_REACHED(); } diff --git a/Tests/LibWeb/Ref/expected/position-absolute-fixed-nested-static-position.html b/Tests/LibWeb/Ref/expected/position-absolute-fixed-nested-static-position.html new file mode 100644 index 00000000000..047c1ceb276 --- /dev/null +++ b/Tests/LibWeb/Ref/expected/position-absolute-fixed-nested-static-position.html @@ -0,0 +1,9 @@ + + +
diff --git a/Tests/LibWeb/Ref/input/position-absolute-and-fixed-nested-static-position.html b/Tests/LibWeb/Ref/input/position-absolute-and-fixed-nested-static-position.html new file mode 100644 index 00000000000..f5e70086338 --- /dev/null +++ b/Tests/LibWeb/Ref/input/position-absolute-and-fixed-nested-static-position.html @@ -0,0 +1,21 @@ + + + +
+
+
+
+
+
+
diff --git a/Tests/LibWeb/Ref/input/position-absolute-nested-static-position.html b/Tests/LibWeb/Ref/input/position-absolute-nested-static-position.html new file mode 100644 index 00000000000..983eb4356c7 --- /dev/null +++ b/Tests/LibWeb/Ref/input/position-absolute-nested-static-position.html @@ -0,0 +1,20 @@ + + + +
+
+
+
+
+
+
diff --git a/Tests/LibWeb/Ref/input/position-fixed-and-absolute-nested-static-position.html b/Tests/LibWeb/Ref/input/position-fixed-and-absolute-nested-static-position.html new file mode 100644 index 00000000000..f93dcc9d1c4 --- /dev/null +++ b/Tests/LibWeb/Ref/input/position-fixed-and-absolute-nested-static-position.html @@ -0,0 +1,21 @@ + + + +
+
+
+
+
+
+
diff --git a/Tests/LibWeb/Ref/input/position-fixed-nested-static-position.html b/Tests/LibWeb/Ref/input/position-fixed-nested-static-position.html new file mode 100644 index 00000000000..26c421937af --- /dev/null +++ b/Tests/LibWeb/Ref/input/position-fixed-nested-static-position.html @@ -0,0 +1,20 @@ + + + +
+
+
+
+
+
+
From 1ffb0ca311960c21f87e23c05098726ecb20f714 Mon Sep 17 00:00:00 2001 From: InvalidUsernameException Date: Mon, 13 Oct 2025 13:34:19 +0200 Subject: [PATCH 02/27] LibWeb: Remove redundant function parameter This function can really only be called with a box and its containing block, otherwise the results are not meaningful. Instead of passing these two dependent values separatly, reduce it down to a single parameter to not make the function appear more general than it is. --- Libraries/LibWeb/Layout/FormattingContext.cpp | 6 +++--- Libraries/LibWeb/Layout/FormattingContext.h | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Libraries/LibWeb/Layout/FormattingContext.cpp b/Libraries/LibWeb/Layout/FormattingContext.cpp index 1e5377babc0..726be189a82 100644 --- a/Libraries/LibWeb/Layout/FormattingContext.cpp +++ b/Libraries/LibWeb/Layout/FormattingContext.cpp @@ -1163,13 +1163,13 @@ void FormattingContext::compute_height_for_absolutely_positioned_non_replaced_el box_state.margin_bottom = margin_bottom.to_px_or_zero(box, width_of_containing_block); } -CSSPixelRect FormattingContext::content_box_rect_in_static_position_ancestor_coordinate_space(Box const& box, Box const& ancestor_box) const +CSSPixelRect FormattingContext::content_box_rect_in_static_position_ancestor_coordinate_space(Box const& box) const { auto box_used_values = m_state.get(box); CSSPixelRect rect = { { 0, 0 }, box_used_values.content_size() }; VERIFY(box_used_values.offset.is_zero()); // Set as result of this calculation for (auto const* current = box.static_position_containing_block(); current; current = current->containing_block()) { - if (current == &ancestor_box) + if (current == box.containing_block()) return rect; auto const& current_state = m_state.get(*current); rect.translate_by(current_state.offset); @@ -1247,7 +1247,7 @@ void FormattingContext::layout_absolutely_positioned_element(Box const& box, Ava CSSPixelPoint used_offset; auto static_position = m_state.get(box).static_position(); - auto offset_to_static_parent = content_box_rect_in_static_position_ancestor_coordinate_space(box, *box.containing_block()); + auto offset_to_static_parent = content_box_rect_in_static_position_ancestor_coordinate_space(box); static_position += offset_to_static_parent.location(); if (box.computed_values().inset().top().is_auto() && box.computed_values().inset().bottom().is_auto()) { diff --git a/Libraries/LibWeb/Layout/FormattingContext.h b/Libraries/LibWeb/Layout/FormattingContext.h index e7188b4f7dd..c5b0b91cf24 100644 --- a/Libraries/LibWeb/Layout/FormattingContext.h +++ b/Libraries/LibWeb/Layout/FormattingContext.h @@ -87,7 +87,7 @@ public: [[nodiscard]] CSSPixelRect content_box_rect(LayoutState::UsedValues const&) const; [[nodiscard]] CSSPixelRect content_box_rect_in_ancestor_coordinate_space(LayoutState::UsedValues const&, Box const& ancestor_box) const; [[nodiscard]] CSSPixels box_baseline(Box const&) const; - [[nodiscard]] CSSPixelRect content_box_rect_in_static_position_ancestor_coordinate_space(Box const&, Box const& ancestor_box) const; + [[nodiscard]] CSSPixelRect content_box_rect_in_static_position_ancestor_coordinate_space(Box const&) const; [[nodiscard]] CSSPixels containing_block_width_for(NodeWithStyleAndBoxModelMetrics const&) const; From 81aeee3fb478f5a05c1378a85ef999384a73d2c7 Mon Sep 17 00:00:00 2001 From: Aliaksandr Kalenik Date: Mon, 13 Oct 2025 16:58:57 +0200 Subject: [PATCH 03/27] LibWeb: Get rid of `PaintableBox::is_viewport()` This function used layout node pointer to check if it's corresponding to viewport. There is no need for that, since `is_viewport_paintable()` does exactly the same check without going through layout node. --- Libraries/LibWeb/DOM/Element.cpp | 2 +- Libraries/LibWeb/Painting/BackgroundPainting.cpp | 2 +- Libraries/LibWeb/Painting/PaintableBox.cpp | 12 ++++++------ Libraries/LibWeb/Painting/PaintableBox.h | 2 -- Libraries/LibWeb/Painting/ViewportPaintable.cpp | 2 +- 5 files changed, 9 insertions(+), 11 deletions(-) diff --git a/Libraries/LibWeb/DOM/Element.cpp b/Libraries/LibWeb/DOM/Element.cpp index efc08803405..27203e07c8d 100644 --- a/Libraries/LibWeb/DOM/Element.cpp +++ b/Libraries/LibWeb/DOM/Element.cpp @@ -1245,7 +1245,7 @@ Vector Element::get_client_rects() const Vector rects; if (auto const* paintable_box = this->paintable_box()) { transform = Gfx::extract_2d_affine_transform(paintable_box->transform()); - for (auto const* containing_block = paintable->containing_block(); !containing_block->is_viewport(); containing_block = containing_block->containing_block()) { + for (auto const* containing_block = paintable->containing_block(); !containing_block->is_viewport_paintable(); containing_block = containing_block->containing_block()) { transform = Gfx::extract_2d_affine_transform(containing_block->transform()).multiply(transform); } diff --git a/Libraries/LibWeb/Painting/BackgroundPainting.cpp b/Libraries/LibWeb/Painting/BackgroundPainting.cpp index 9c5738c61d6..dd3a0593136 100644 --- a/Libraries/LibWeb/Painting/BackgroundPainting.cpp +++ b/Libraries/LibWeb/Painting/BackgroundPainting.cpp @@ -167,7 +167,7 @@ void paint_background(DisplayListRecordingContext& context, PaintableBox const& background_positioning_area.set_location(paintable_box.layout_node().root().navigable()->viewport_scroll_offset()); break; case CSS::BackgroundAttachment::Local: - if (!paintable_box.is_viewport()) { + if (!paintable_box.is_viewport_paintable()) { auto scroll_offset = paintable_box.scroll_offset(); background_positioning_area.translate_by(-scroll_offset.x(), -scroll_offset.y()); } diff --git a/Libraries/LibWeb/Painting/PaintableBox.cpp b/Libraries/LibWeb/Painting/PaintableBox.cpp index 27f720b08b9..08f08774ed3 100644 --- a/Libraries/LibWeb/Painting/PaintableBox.cpp +++ b/Libraries/LibWeb/Painting/PaintableBox.cpp @@ -95,7 +95,7 @@ PaintableWithLines::~PaintableWithLines() CSSPixelPoint PaintableBox::scroll_offset() const { - if (is_viewport()) { + if (is_viewport_paintable()) { auto navigable = document().navigable(); VERIFY(navigable); return navigable->viewport_scroll_offset(); @@ -361,7 +361,7 @@ bool PaintableBox::could_be_scrolled_by_wheel_event(ScrollDirection direction) c auto scrollable_overflow_size = direction == ScrollDirection::Horizontal ? scrollable_overflow_rect->width() : scrollable_overflow_rect->height(); auto scrollport_size = direction == ScrollDirection::Horizontal ? absolute_padding_box_rect().width() : absolute_padding_box_rect().height(); auto overflow_value_allows_scrolling = overflow == CSS::Overflow::Auto || overflow == CSS::Overflow::Scroll; - if ((is_viewport() && overflow != CSS::Overflow::Hidden) || overflow_value_allows_scrolling) + if ((is_viewport_paintable() && overflow != CSS::Overflow::Hidden) || overflow_value_allows_scrolling) return scrollable_overflow_size > scrollport_size; return false; } @@ -488,7 +488,7 @@ void PaintableBox::paint(DisplayListRecordingContext& context, PaintPhase phase) } } - if (phase == PaintPhase::Overlay && (g_paint_viewport_scrollbars || !is_viewport()) && computed_values().scrollbar_width() != CSS::ScrollbarWidth::None) { + if (phase == PaintPhase::Overlay && (g_paint_viewport_scrollbars || !is_viewport_paintable()) && computed_values().scrollbar_width() != CSS::ScrollbarWidth::None) { auto scrollbar_colors = computed_values().scrollbar_color(); if (auto scrollbar_data = compute_scrollbar_data(ScrollDirection::Vertical); scrollbar_data.has_value()) { auto gutter_rect = context.rounded_device_rect(scrollbar_data->gutter_rect).to_type(); @@ -1153,10 +1153,10 @@ void PaintableBox::scroll_to_mouse_position(CSSPixelPoint position) auto scroll_position_in_pixels = CSSPixels::nearest_value_for(scroll_position * (scrollable_overflow_size - padding_size)); // Set the new scroll offset. - auto new_scroll_offset = is_viewport() ? document().navigable()->viewport_scroll_offset() : scroll_offset(); + auto new_scroll_offset = is_viewport_paintable() ? document().navigable()->viewport_scroll_offset() : scroll_offset(); new_scroll_offset.set_primary_offset_for_orientation(orientation, scroll_position_in_pixels); - if (is_viewport()) + if (is_viewport_paintable()) document().navigable()->perform_scroll_of_viewport(new_scroll_offset); else (void)set_scroll_offset(new_scroll_offset); @@ -1213,7 +1213,7 @@ TraversalDecision PaintableBox::hit_test(CSSPixelPoint position, HitTestType typ if (hit_test_scrollbars(position, callback) == TraversalDecision::Break) return TraversalDecision::Break; - if (is_viewport()) { + if (is_viewport_paintable()) { auto& viewport_paintable = const_cast(static_cast(*this)); viewport_paintable.build_stacking_context_tree_if_needed(); viewport_paintable.document().update_paint_and_hit_testing_properties_if_needed(); diff --git a/Libraries/LibWeb/Painting/PaintableBox.h b/Libraries/LibWeb/Painting/PaintableBox.h index a9978b14d88..3f321249d02 100644 --- a/Libraries/LibWeb/Painting/PaintableBox.h +++ b/Libraries/LibWeb/Painting/PaintableBox.h @@ -213,8 +213,6 @@ public: Optional get_clip_rect() const; - bool is_viewport() const { return layout_node_with_style_and_box_metrics().is_viewport(); } - virtual bool wants_mouse_events() const override; CSSPixelRect transform_box_rect() const; diff --git a/Libraries/LibWeb/Painting/ViewportPaintable.cpp b/Libraries/LibWeb/Painting/ViewportPaintable.cpp index a6f7a47659c..f400803b946 100644 --- a/Libraries/LibWeb/Painting/ViewportPaintable.cpp +++ b/Libraries/LibWeb/Painting/ViewportPaintable.cpp @@ -139,7 +139,7 @@ void ViewportPaintable::assign_clip_frames() paintable_box.set_own_clip_frame(clip_frame.value()); } } - for (auto block = paintable.containing_block(); !block->is_viewport(); block = block->containing_block()) { + for (auto block = paintable.containing_block(); !block->is_viewport_paintable(); block = block->containing_block()) { if (auto clip_frame = clip_state.get(block); clip_frame.has_value()) { if (paintable.is_paintable_box()) { auto& paintable_box = static_cast(paintable); From 9e838cffb4296366415b8dbee459b26b7935ca31 Mon Sep 17 00:00:00 2001 From: Aliaksandr Kalenik Date: Mon, 13 Oct 2025 17:13:22 +0200 Subject: [PATCH 04/27] LibWeb: Copy "is inert" attribute into Paintable ...instead of reaching into DOM tree during hit-testing in order to figure out if an element is inert. This is a part of the effert to make possible running hit-testing solely based on data contained by the paintable tree. --- Libraries/LibWeb/HTML/HTMLElement.cpp | 3 +++ Libraries/LibWeb/Painting/Paintable.cpp | 19 ++++++++++++------- Libraries/LibWeb/Painting/Paintable.h | 3 ++- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/Libraries/LibWeb/HTML/HTMLElement.cpp b/Libraries/LibWeb/HTML/HTMLElement.cpp index 086d0cd348d..1aa171c2468 100644 --- a/Libraries/LibWeb/HTML/HTMLElement.cpp +++ b/Libraries/LibWeb/HTML/HTMLElement.cpp @@ -788,6 +788,9 @@ void HTMLElement::set_subtree_inertness(bool is_inert) html_element.set_inert(is_inert); return TraversalDecision::Continue; }); + + if (auto paintable_box = this->paintable_box()) + paintable_box->set_needs_paint_only_properties_update(true); } WebIDL::ExceptionOr HTMLElement::cloned(Web::DOM::Node& copy, bool clone_children) const diff --git a/Libraries/LibWeb/Painting/Paintable.cpp b/Libraries/LibWeb/Painting/Paintable.cpp index 33f1286329e..07bdaea7f4a 100644 --- a/Libraries/LibWeb/Painting/Paintable.cpp +++ b/Libraries/LibWeb/Painting/Paintable.cpp @@ -52,6 +52,17 @@ String Paintable::debug_description() const return MUST(String::formatted("{}({})", class_name(), layout_node().debug_description())); } +void Paintable::resolve_paint_properties() +{ + m_visible_for_hit_testing = true; + if (auto dom_node = this->dom_node(); dom_node && dom_node->is_inert()) { + // https://html.spec.whatwg.org/multipage/interaction.html#inert-subtrees + // When a node is inert: + // - Hit-testing must act as if the 'pointer-events' CSS property were set to 'none'. + m_visible_for_hit_testing = false; + } +} + bool Paintable::is_visible() const { auto const& computed_values = this->computed_values(); @@ -90,13 +101,7 @@ CSS::ImmutableComputedValues const& Paintable::computed_values() const bool Paintable::visible_for_hit_testing() const { - // https://html.spec.whatwg.org/multipage/interaction.html#inert-subtrees - // When a node is inert: - // - Hit-testing must act as if the 'pointer-events' CSS property were set to 'none'. - if (auto dom_node = this->dom_node(); dom_node && dom_node->is_inert()) - return false; - - return computed_values().pointer_events() != CSS::PointerEvents::None; + return m_visible_for_hit_testing && computed_values().pointer_events() != CSS::PointerEvents::None; } void Paintable::set_dom_node(GC::Ptr dom_node) diff --git a/Libraries/LibWeb/Painting/Paintable.h b/Libraries/LibWeb/Painting/Paintable.h index ba280ad69bd..6689f90005c 100644 --- a/Libraries/LibWeb/Painting/Paintable.h +++ b/Libraries/LibWeb/Painting/Paintable.h @@ -145,7 +145,7 @@ public: SelectionState selection_state() const { return m_selection_state; } void set_selection_state(SelectionState state) { m_selection_state = state; } - virtual void resolve_paint_properties() { } + virtual void resolve_paint_properties(); [[nodiscard]] String debug_description() const; @@ -177,6 +177,7 @@ private: bool m_absolutely_positioned : 1 { false }; bool m_floating : 1 { false }; bool m_inline : 1 { false }; + bool m_visible_for_hit_testing : 1 { true }; bool m_needs_paint_only_properties_update : 1 { true }; }; From f706c883eba896c394c912d3787529bd9ab1fde8 Mon Sep 17 00:00:00 2001 From: Aliaksandr Kalenik Date: Mon, 13 Oct 2025 17:19:05 +0200 Subject: [PATCH 05/27] LibWeb: Don't reach into layout node to check if Paintable is positioned We copy this information into Paintable, so it could be taken directly from there. --- Libraries/LibWeb/Painting/PaintableBox.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Libraries/LibWeb/Painting/PaintableBox.cpp b/Libraries/LibWeb/Painting/PaintableBox.cpp index 08f08774ed3..79da8357f75 100644 --- a/Libraries/LibWeb/Painting/PaintableBox.cpp +++ b/Libraries/LibWeb/Painting/PaintableBox.cpp @@ -1279,7 +1279,7 @@ Optional PaintableBox::hit_test(CSSPixelPoint position, HitTestTy TraversalDecision PaintableBox::hit_test_children(CSSPixelPoint position, HitTestType type, Function const& callback) const { for (auto const* child = last_child(); child; child = child->previous_sibling()) { - if (child->layout_node().is_positioned() && child->computed_values().z_index().value_or(0) == 0) + if (child->is_positioned() && child->computed_values().z_index().value_or(0) == 0) continue; if (child->has_stacking_context()) continue; @@ -1390,7 +1390,7 @@ TraversalDecision PaintableWithLines::hit_test(CSSPixelPoint position, HitTestTy } } - if (!stacking_context() && is_visible() && (!layout_node().is_anonymous() || layout_node().is_positioned()) + if (!stacking_context() && is_visible() && (!layout_node().is_anonymous() || is_positioned()) && absolute_border_box_rect().contains(offset_position_adjusted_by_scroll_offset)) { if (callback(HitTestResult { const_cast(*this) }) == TraversalDecision::Break) return TraversalDecision::Break; From 881ef21d40519f57902bb8993ab3085625588d9e Mon Sep 17 00:00:00 2001 From: Aliaksandr Kalenik Date: Mon, 13 Oct 2025 17:38:52 +0200 Subject: [PATCH 06/27] LibWeb: Get rid of `SVGPathPaintable::layout_box()` It was used exclusively to get corresponding DOM node pointer, which is unnecessary indirection as Paintable owns the DOM node pointer directly. --- Libraries/LibWeb/Painting/SVGPathPaintable.cpp | 10 ++-------- Libraries/LibWeb/Painting/SVGPathPaintable.h | 2 +- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/Libraries/LibWeb/Painting/SVGPathPaintable.cpp b/Libraries/LibWeb/Painting/SVGPathPaintable.cpp index 9db90ef35e1..113b76f7c11 100644 --- a/Libraries/LibWeb/Painting/SVGPathPaintable.cpp +++ b/Libraries/LibWeb/Painting/SVGPathPaintable.cpp @@ -24,11 +24,6 @@ SVGPathPaintable::SVGPathPaintable(Layout::SVGGraphicsBox const& layout_box) { } -Layout::SVGGraphicsBox const& SVGPathPaintable::layout_box() const -{ - return static_cast(layout_node()); -} - TraversalDecision SVGPathPaintable::hit_test(CSSPixelPoint position, HitTestType type, Function const& callback) const { if (!computed_path().has_value()) @@ -55,8 +50,7 @@ void SVGPathPaintable::resolve_paint_properties() { Base::resolve_paint_properties(); - auto& graphics_element = layout_box().dom_node(); - + auto& graphics_element = dom_node(); m_stroke_thickness = graphics_element.stroke_width().value_or(1); m_stroke_dasharray = graphics_element.stroke_dasharray(); m_stroke_dashoffset = graphics_element.stroke_dashoffset().value_or(0); @@ -72,7 +66,7 @@ void SVGPathPaintable::paint(DisplayListRecordingContext& context, PaintPhase ph if (phase != PaintPhase::Foreground) return; - auto& graphics_element = layout_box().dom_node(); + auto& graphics_element = dom_node(); auto const* svg_node = layout_box().first_ancestor_of_type(); auto svg_element_rect = svg_node->paintable_box()->absolute_rect(); diff --git a/Libraries/LibWeb/Painting/SVGPathPaintable.h b/Libraries/LibWeb/Painting/SVGPathPaintable.h index 4678632e3e1..6ed4793136d 100644 --- a/Libraries/LibWeb/Painting/SVGPathPaintable.h +++ b/Libraries/LibWeb/Painting/SVGPathPaintable.h @@ -24,7 +24,7 @@ public: virtual void paint(DisplayListRecordingContext&, PaintPhase) const override; - Layout::SVGGraphicsBox const& layout_box() const; + SVG::SVGGraphicsElement const& dom_node() const { return as(*Paintable::dom_node()); } void set_computed_path(Gfx::Path path) { From 4853e2ffb1764e74cf860a276ce4c75fdd665f55 Mon Sep 17 00:00:00 2001 From: Aliaksandr Kalenik Date: Mon, 13 Oct 2025 18:02:48 +0200 Subject: [PATCH 07/27] LibWeb: Don't reach into layout node to check if paintable is SVG --- Libraries/LibWeb/Painting/StackingContext.cpp | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Libraries/LibWeb/Painting/StackingContext.cpp b/Libraries/LibWeb/Painting/StackingContext.cpp index 0a2590e735c..807581f5023 100644 --- a/Libraries/LibWeb/Painting/StackingContext.cpp +++ b/Libraries/LibWeb/Painting/StackingContext.cpp @@ -86,7 +86,7 @@ static PaintPhase to_paint_phase(StackingContext::StackingContextPaintPhase phas void StackingContext::paint_node_as_stacking_context(Paintable const& paintable, DisplayListRecordingContext& context) { - if (paintable.layout_node().is_svg_svg_box()) { + if (paintable.is_svg_svg_paintable()) { paint_svg(context, static_cast(paintable), PaintPhase::Foreground); return; } @@ -121,7 +121,7 @@ void StackingContext::paint_descendants(DisplayListRecordingContext& context, Pa if (child.has_stacking_context()) return IterationDecision::Continue; - if (child.layout_node().is_svg_svg_box()) { + if (child.is_svg_svg_paintable()) { paint_svg(context, static_cast(child), to_paint_phase(phase)); return IterationDecision::Continue; } @@ -198,15 +198,15 @@ void StackingContext::paint_descendants(DisplayListRecordingContext& context, Pa void StackingContext::paint_child(DisplayListRecordingContext& context, StackingContext const& child) { - VERIFY(!child.paintable_box().layout_node().is_svg_box()); + VERIFY(!child.paintable_box().is_svg_paintable()); const_cast(child).set_last_paint_generation_id(context.paint_generation_id()); child.paint(context); } void StackingContext::paint_internal(DisplayListRecordingContext& context) const { - VERIFY(!paintable_box().layout_node().is_svg_box()); - if (paintable_box().layout_node().is_svg_svg_box()) { + VERIFY(!paintable_box().is_svg_paintable()); + if (paintable_box().is_svg_svg_paintable()) { auto const& svg_svg_paintable = static_cast(paintable_box()); paint_node(svg_svg_paintable, context, PaintPhase::Background); paint_node(svg_svg_paintable, context, PaintPhase::Border); From 207f313b4b71516c05f2f72ce128cf08cd5dca92 Mon Sep 17 00:00:00 2001 From: Aliaksandr Kalenik Date: Mon, 13 Oct 2025 18:10:52 +0200 Subject: [PATCH 08/27] LibWeb: Delete unused `Document::set_visibility_state()` --- Libraries/LibWeb/DOM/Document.cpp | 5 ----- Libraries/LibWeb/DOM/Document.h | 3 --- 2 files changed, 8 deletions(-) diff --git a/Libraries/LibWeb/DOM/Document.cpp b/Libraries/LibWeb/DOM/Document.cpp index e7be1176602..3b081ec092c 100644 --- a/Libraries/LibWeb/DOM/Document.cpp +++ b/Libraries/LibWeb/DOM/Document.cpp @@ -3329,11 +3329,6 @@ StringView Document::visibility_state() const VERIFY_NOT_REACHED(); } -void Document::set_visibility_state(Badge, HTML::VisibilityState visibility_state) -{ - m_visibility_state = visibility_state; -} - // https://html.spec.whatwg.org/multipage/interaction.html#update-the-visibility-state void Document::update_the_visibility_state(HTML::VisibilityState visibility_state) { diff --git a/Libraries/LibWeb/DOM/Document.h b/Libraries/LibWeb/DOM/Document.h index 20925cf9878..5e19597a9e2 100644 --- a/Libraries/LibWeb/DOM/Document.h +++ b/Libraries/LibWeb/DOM/Document.h @@ -542,9 +542,6 @@ public: // https://html.spec.whatwg.org/multipage/interaction.html#update-the-visibility-state void update_the_visibility_state(HTML::VisibilityState); - // NOTE: This does not fire any events, unlike update_the_visibility_state(). - void set_visibility_state(Badge, HTML::VisibilityState); - void run_the_resize_steps(); void run_the_scroll_steps(); From 61185d98aa1de0f96fe7c63d6b097c4ac07cc436 Mon Sep 17 00:00:00 2001 From: stelar7 Date: Mon, 13 Oct 2025 10:14:56 +0200 Subject: [PATCH 09/27] LibWeb/IDB: Adjust how negative numbers increment the key generator Directly mapping a negative double to a u64 causes it to wrap around to the max value. We work around this here by comparing as doubles, and only incrementing the generator if the new value is greater Fixes #6455 --- Libraries/LibWeb/IndexedDB/Internal/Algorithms.cpp | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Libraries/LibWeb/IndexedDB/Internal/Algorithms.cpp b/Libraries/LibWeb/IndexedDB/Internal/Algorithms.cpp index 90cfa285c7a..48d8258e8e9 100644 --- a/Libraries/LibWeb/IndexedDB/Internal/Algorithms.cpp +++ b/Libraries/LibWeb/IndexedDB/Internal/Algorithms.cpp @@ -1293,20 +1293,21 @@ void possibly_update_the_key_generator(GC::Ref store, GC::Ref return; // 2. Let value be the value of key. - auto temp_value = key->value_as_double(); + auto value = key->value_as_double(); // 3. Set value to the minimum of value and 2^53 (9007199254740992). - temp_value = min(temp_value, MAX_KEY_GENERATOR_VALUE); + value = min(value, MAX_KEY_GENERATOR_VALUE); // 4. Set value to the largest integer not greater than value. - u64 value = floor(temp_value); + value = floor(value); // 5. Let generator be store’s key generator. auto& generator = store->key_generator(); // 6. If value is greater than or equal to generator’s current number, then set generator’s current number to value + 1. - if (value >= generator.current_number()) - generator.set(value + 1); + if (value >= static_cast(generator.current_number())) { + generator.set(static_cast(value + 1)); + } } // https://w3c.github.io/IndexedDB/#inject-a-key-into-a-value-using-a-key-path From 2f5481284d23ee95401a2fed52b0171a243f6b3e Mon Sep 17 00:00:00 2001 From: Tim Ledbetter Date: Mon, 13 Oct 2025 08:28:36 +0100 Subject: [PATCH 10/27] Meta: Don't attempt to download WPT test resources from external URLs --- Meta/import-wpt-test.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Meta/import-wpt-test.py b/Meta/import-wpt-test.py index 496e60bda7d..669c39e4542 100755 --- a/Meta/import-wpt-test.py +++ b/Meta/import-wpt-test.py @@ -157,6 +157,10 @@ def map_to_path( if source.resource.startswith("/") or not is_resource: file_path = Path(base_directory, source.resource.lstrip("/")) else: + parsed_url = urlparse(source.resource) + if parsed_url.scheme != "": + print(f"Skipping '{source.resource}'. Downloading external resources is not supported.") + continue # Add it as a sibling path if it's a relative resource sibling_location = Path(resource_path).parent parent_directory = Path(base_directory, sibling_location) From 0bdb831c689f105f26ffab95d5f7154680d6f0d4 Mon Sep 17 00:00:00 2001 From: Tim Ledbetter Date: Mon, 13 Oct 2025 08:34:35 +0100 Subject: [PATCH 11/27] LibWeb: Avoid null dereference in ListItemBox specified content check --- .../LibWeb/Layout/BlockFormattingContext.cpp | 10 ++- .../css-pseudo/marker-computed-content.txt | 15 +++++ .../css-pseudo/marker-computed-content.html | 63 +++++++++++++++++++ 3 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 Tests/LibWeb/Text/expected/wpt-import/css/css-pseudo/marker-computed-content.txt create mode 100644 Tests/LibWeb/Text/input/wpt-import/css/css-pseudo/marker-computed-content.html diff --git a/Libraries/LibWeb/Layout/BlockFormattingContext.cpp b/Libraries/LibWeb/Layout/BlockFormattingContext.cpp index 693765f7892..7c4041fb3dc 100644 --- a/Libraries/LibWeb/Layout/BlockFormattingContext.cpp +++ b/Libraries/LibWeb/Layout/BlockFormattingContext.cpp @@ -809,14 +809,18 @@ void BlockFormattingContext::layout_block_level_box(Box const& box, BlockContain // This monster basically means: "a ListItemBox that does not have specified content in the ::marker pseudo-element". // This happens for ::marker with content 'normal'. // FIXME: We currently so not support ListItemBox-es generated by pseudo-elements. We will need to, eventually. - ListItemBox const* li_box = as_if(box); - bool is_list_item_box_without_css_content = li_box && (!(box.dom_node() && box.dom_node()->is_element() && as_if(box.dom_node())->computed_properties(CSS::PseudoElement::Marker)->property(CSS::PropertyID::Content).is_content())); + auto const* li_box = as_if(box); + auto is_list_item_box_without_css_content = li_box != nullptr; + if (auto const* dom_node = as_if(box.dom_node()); li_box && dom_node) { + if (auto const computed_properties = dom_node->computed_properties(CSS::PseudoElement::Marker)) + is_list_item_box_without_css_content = !computed_properties->property(CSS::PropertyID::Content).is_content(); + } // Before we insert the children of a list item we need to know the location of the marker. // If we do not do this then left-floating elements inside the list item will push the marker to the right, // in some cases even causing it to overlap with the non-floating content of the list. CSSPixels left_space_before_children_formatted; - if (is_list_item_box_without_css_content) { + if (is_list_item_box_without_css_content && li_box->marker()) { // We need to ensure that our height and width are final before we calculate our left offset. // Otherwise, the y at which we calculate the intrusion by floats might be incorrect. ensure_sizes_correct_for_left_offset_calculation(*li_box); diff --git a/Tests/LibWeb/Text/expected/wpt-import/css/css-pseudo/marker-computed-content.txt b/Tests/LibWeb/Text/expected/wpt-import/css/css-pseudo/marker-computed-content.txt new file mode 100644 index 00000000000..6a335b78960 --- /dev/null +++ b/Tests/LibWeb/Text/expected/wpt-import/css/css-pseudo/marker-computed-content.txt @@ -0,0 +1,15 @@ +Harness status: OK + +Found 10 tests + +10 Pass +Pass Computed 'content' for list-item ::marker, variant default +Pass Computed 'content' for list-item ::marker, variant normal +Pass Computed 'content' for list-item ::marker, variant string +Pass Computed 'content' for list-item ::marker, variant image +Pass Computed 'content' for list-item ::marker, variant none +Pass Computed 'content' for non-list-item ::marker, variant default +Pass Computed 'content' for non-list-item ::marker, variant normal +Pass Computed 'content' for non-list-item ::marker, variant string +Pass Computed 'content' for non-list-item ::marker, variant image +Pass Computed 'content' for non-list-item ::marker, variant none \ No newline at end of file diff --git a/Tests/LibWeb/Text/input/wpt-import/css/css-pseudo/marker-computed-content.html b/Tests/LibWeb/Text/input/wpt-import/css/css-pseudo/marker-computed-content.html new file mode 100644 index 00000000000..8e60b242013 --- /dev/null +++ b/Tests/LibWeb/Text/input/wpt-import/css/css-pseudo/marker-computed-content.html @@ -0,0 +1,63 @@ + + +CSS Pseudo-Elements Test: Computed size of ::marker + + + + + + +
+
    +
  1. item
  2. +
  3. item
  4. +
  5. item
  6. +
  7. item
  8. +
  9. item
  10. +
+
    +
  1. item
  2. +
  3. item
  4. +
  5. item
  6. +
  7. item
  8. +
  9. item
  10. +
+ + + From 05f3bd0fa86ff8de24393de6152e99b21ae656df Mon Sep 17 00:00:00 2001 From: ayeteadoe Date: Fri, 10 Oct 2025 21:38:15 -0700 Subject: [PATCH 12/27] Tests/LibWeb: Import several scaling createImageBitmap() tests --- .../createImageBitmap-drawImage.txt | 61 +++++++++++++ .../createImageBitmap-drawImage.html | 87 +++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 Tests/LibWeb/Text/expected/wpt-import/html/canvas/element/manual/imagebitmap/createImageBitmap-drawImage.txt create mode 100644 Tests/LibWeb/Text/input/wpt-import/html/canvas/element/manual/imagebitmap/createImageBitmap-drawImage.html diff --git a/Tests/LibWeb/Text/expected/wpt-import/html/canvas/element/manual/imagebitmap/createImageBitmap-drawImage.txt b/Tests/LibWeb/Text/expected/wpt-import/html/canvas/element/manual/imagebitmap/createImageBitmap-drawImage.txt new file mode 100644 index 00000000000..ecbd0fba18c --- /dev/null +++ b/Tests/LibWeb/Text/expected/wpt-import/html/canvas/element/manual/imagebitmap/createImageBitmap-drawImage.txt @@ -0,0 +1,61 @@ +Harness status: OK + +Found 55 tests + +14 Pass +41 Fail +Pass createImageBitmap from an HTMLCanvasElement, and drawImage on the created ImageBitmap +Fail createImageBitmap from an HTMLCanvasElement scaled down, and drawImage on the created ImageBitmap +Fail createImageBitmap from an HTMLCanvasElement scaled up, and drawImage on the created ImageBitmap +Fail createImageBitmap from an HTMLCanvasElement resized, and drawImage on the created ImageBitmap +Pass createImageBitmap from an HTMLCanvasElement with negative sw/sh, and drawImage on the created ImageBitmap +Fail createImageBitmap from an HTMLVideoElement, and drawImage on the created ImageBitmap +Fail createImageBitmap from an HTMLVideoElement scaled down, and drawImage on the created ImageBitmap +Fail createImageBitmap from an HTMLVideoElement scaled up, and drawImage on the created ImageBitmap +Fail createImageBitmap from an HTMLVideoElement resized, and drawImage on the created ImageBitmap +Fail createImageBitmap from an HTMLVideoElement with negative sw/sh, and drawImage on the created ImageBitmap +Fail createImageBitmap from an HTMLVideoElement from a data URL, and drawImage on the created ImageBitmap +Fail createImageBitmap from an HTMLVideoElement from a data URL scaled down, and drawImage on the created ImageBitmap +Fail createImageBitmap from an HTMLVideoElement from a data URL scaled up, and drawImage on the created ImageBitmap +Fail createImageBitmap from an HTMLVideoElement from a data URL resized, and drawImage on the created ImageBitmap +Fail createImageBitmap from an HTMLVideoElement from a data URL with negative sw/sh, and drawImage on the created ImageBitmap +Pass createImageBitmap from a bitmap HTMLImageElement, and drawImage on the created ImageBitmap +Fail createImageBitmap from a bitmap HTMLImageElement scaled down, and drawImage on the created ImageBitmap +Fail createImageBitmap from a bitmap HTMLImageElement scaled up, and drawImage on the created ImageBitmap +Fail createImageBitmap from a bitmap HTMLImageElement resized, and drawImage on the created ImageBitmap +Pass createImageBitmap from a bitmap HTMLImageElement with negative sw/sh, and drawImage on the created ImageBitmap +Pass createImageBitmap from a vector HTMLImageElement, and drawImage on the created ImageBitmap +Fail createImageBitmap from a vector HTMLImageElement scaled down, and drawImage on the created ImageBitmap +Fail createImageBitmap from a vector HTMLImageElement scaled up, and drawImage on the created ImageBitmap +Fail createImageBitmap from a vector HTMLImageElement resized, and drawImage on the created ImageBitmap +Pass createImageBitmap from a vector HTMLImageElement with negative sw/sh, and drawImage on the created ImageBitmap +Pass createImageBitmap from a bitmap SVGImageElement, and drawImage on the created ImageBitmap +Fail createImageBitmap from a bitmap SVGImageElement scaled down, and drawImage on the created ImageBitmap +Fail createImageBitmap from a bitmap SVGImageElement scaled up, and drawImage on the created ImageBitmap +Fail createImageBitmap from a bitmap SVGImageElement resized, and drawImage on the created ImageBitmap +Pass createImageBitmap from a bitmap SVGImageElement with negative sw/sh, and drawImage on the created ImageBitmap +Pass createImageBitmap from a vector SVGImageElement, and drawImage on the created ImageBitmap +Fail createImageBitmap from a vector SVGImageElement scaled down, and drawImage on the created ImageBitmap +Fail createImageBitmap from a vector SVGImageElement scaled up, and drawImage on the created ImageBitmap +Fail createImageBitmap from a vector SVGImageElement resized, and drawImage on the created ImageBitmap +Pass createImageBitmap from a vector SVGImageElement with negative sw/sh, and drawImage on the created ImageBitmap +Fail createImageBitmap from an OffscreenCanvas, and drawImage on the created ImageBitmap +Fail createImageBitmap from an OffscreenCanvas scaled down, and drawImage on the created ImageBitmap +Fail createImageBitmap from an OffscreenCanvas scaled up, and drawImage on the created ImageBitmap +Fail createImageBitmap from an OffscreenCanvas resized, and drawImage on the created ImageBitmap +Fail createImageBitmap from an OffscreenCanvas with negative sw/sh, and drawImage on the created ImageBitmap +Pass createImageBitmap from an ImageData, and drawImage on the created ImageBitmap +Fail createImageBitmap from an ImageData scaled down, and drawImage on the created ImageBitmap +Fail createImageBitmap from an ImageData scaled up, and drawImage on the created ImageBitmap +Fail createImageBitmap from an ImageData resized, and drawImage on the created ImageBitmap +Pass createImageBitmap from an ImageData with negative sw/sh, and drawImage on the created ImageBitmap +Fail createImageBitmap from an ImageBitmap, and drawImage on the created ImageBitmap +Fail createImageBitmap from an ImageBitmap scaled down, and drawImage on the created ImageBitmap +Fail createImageBitmap from an ImageBitmap scaled up, and drawImage on the created ImageBitmap +Fail createImageBitmap from an ImageBitmap resized, and drawImage on the created ImageBitmap +Fail createImageBitmap from an ImageBitmap with negative sw/sh, and drawImage on the created ImageBitmap +Pass createImageBitmap from a Blob, and drawImage on the created ImageBitmap +Fail createImageBitmap from a Blob scaled down, and drawImage on the created ImageBitmap +Fail createImageBitmap from a Blob scaled up, and drawImage on the created ImageBitmap +Fail createImageBitmap from a Blob resized, and drawImage on the created ImageBitmap +Pass createImageBitmap from a Blob with negative sw/sh, and drawImage on the created ImageBitmap \ No newline at end of file diff --git a/Tests/LibWeb/Text/input/wpt-import/html/canvas/element/manual/imagebitmap/createImageBitmap-drawImage.html b/Tests/LibWeb/Text/input/wpt-import/html/canvas/element/manual/imagebitmap/createImageBitmap-drawImage.html new file mode 100644 index 00000000000..00db0783278 --- /dev/null +++ b/Tests/LibWeb/Text/input/wpt-import/html/canvas/element/manual/imagebitmap/createImageBitmap-drawImage.html @@ -0,0 +1,87 @@ + + +createImageBitmap + drawImage test + + + + + + + + + + From 0d5136ae5ce4eac1c44a7f3129d5274b57e455ca Mon Sep 17 00:00:00 2001 From: ayeteadoe Date: Fri, 10 Oct 2025 22:38:09 -0700 Subject: [PATCH 13/27] LibWeb: Add support for bitmap scaling in createImageBitmap() --- Libraries/LibGfx/Bitmap.cpp | 23 +++++++++ Libraries/LibGfx/Bitmap.h | 3 ++ Libraries/LibWeb/HTML/ImageBitmap.h | 2 + Libraries/LibWeb/HTML/ImageBitmap.idl | 2 +- .../LibWeb/HTML/WindowOrWorkerGlobalScope.cpp | 47 +++++++++++++++++-- .../createImageBitmap-drawImage.txt | 46 +++++++++--------- 6 files changed, 96 insertions(+), 27 deletions(-) diff --git a/Libraries/LibGfx/Bitmap.cpp b/Libraries/LibGfx/Bitmap.cpp index 8f0a2a4aea6..c5b0a98abcd 100644 --- a/Libraries/LibGfx/Bitmap.cpp +++ b/Libraries/LibGfx/Bitmap.cpp @@ -10,6 +10,11 @@ #include #include #include +#include + +#include +#include +#include #include #ifdef AK_OS_MACOS @@ -184,6 +189,24 @@ ErrorOr> Bitmap::cropped(Gfx::IntRect crop, Gfx::Colo return new_bitmap; } +ErrorOr> Bitmap::scaled(int const width, int const height, ScalingMode const scaling_mode) const +{ + auto const source_info = SkImageInfo::Make(this->width(), this->height(), to_skia_color_type(format()), to_skia_alpha_type(format(), alpha_type()), nullptr); + SkPixmap const source_sk_pixmap(source_info, begin(), pitch()); + SkBitmap source_sk_bitmap; + source_sk_bitmap.installPixels(source_sk_pixmap); + source_sk_bitmap.setImmutable(); + + auto scaled_bitmap = TRY(Gfx::Bitmap::create(format(), alpha_type(), { width, height })); + auto const scaled_info = SkImageInfo::Make(scaled_bitmap->width(), scaled_bitmap->height(), to_skia_color_type(scaled_bitmap->format()), to_skia_alpha_type(scaled_bitmap->format(), scaled_bitmap->alpha_type()), nullptr); + SkPixmap const scaled_sk_pixmap(scaled_info, scaled_bitmap->begin(), scaled_bitmap->pitch()); + + sk_sp source_sk_image = source_sk_bitmap.asImage(); + if (!source_sk_image->scalePixels(scaled_sk_pixmap, to_skia_sampling_options(scaling_mode))) + return Error::from_string_literal("Unable to scale pixels for bitmap"); + return scaled_bitmap; +} + ErrorOr> Bitmap::to_bitmap_backed_by_anonymous_buffer() const { if (m_buffer.is_valid()) { diff --git a/Libraries/LibGfx/Bitmap.h b/Libraries/LibGfx/Bitmap.h index 682becefd70..31c7aa5619d 100644 --- a/Libraries/LibGfx/Bitmap.h +++ b/Libraries/LibGfx/Bitmap.h @@ -13,6 +13,7 @@ #include #include #include +#include namespace Gfx { @@ -74,6 +75,8 @@ public: ErrorOr> clone() const; ErrorOr> cropped(Gfx::IntRect, Gfx::Color outside_color = Gfx::Color::Black) const; + ErrorOr> scaled(int width, int height, ScalingMode scaling_mode) const; + ErrorOr> to_bitmap_backed_by_anonymous_buffer() const; [[nodiscard]] ShareableBitmap to_shareable_bitmap() const; diff --git a/Libraries/LibWeb/HTML/ImageBitmap.h b/Libraries/LibWeb/HTML/ImageBitmap.h index b14d996b9ea..42922f8ac28 100644 --- a/Libraries/LibWeb/HTML/ImageBitmap.h +++ b/Libraries/LibWeb/HTML/ImageBitmap.h @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -23,6 +24,7 @@ struct ImageBitmapOptions { // FIXME: Implement the rest of the fields Optional resize_width; Optional resize_height; + Bindings::ResizeQuality resize_quality = Bindings::ResizeQuality::Low; }; class ImageBitmap final : public Bindings::PlatformObject diff --git a/Libraries/LibWeb/HTML/ImageBitmap.idl b/Libraries/LibWeb/HTML/ImageBitmap.idl index c115c98e940..57324dea0ce 100644 --- a/Libraries/LibWeb/HTML/ImageBitmap.idl +++ b/Libraries/LibWeb/HTML/ImageBitmap.idl @@ -28,5 +28,5 @@ dictionary ImageBitmapOptions { // FIXME: ColorSpaceConversion colorSpaceConversion = "default"; [EnforceRange] unsigned long resizeWidth; [EnforceRange] unsigned long resizeHeight; - // FIXME: ResizeQuality resizeQuality = "low"; + ResizeQuality resizeQuality = "low"; }; diff --git a/Libraries/LibWeb/HTML/WindowOrWorkerGlobalScope.cpp b/Libraries/LibWeb/HTML/WindowOrWorkerGlobalScope.cpp index 0e74ccdcec2..f1fa19da64d 100644 --- a/Libraries/LibWeb/HTML/WindowOrWorkerGlobalScope.cpp +++ b/Libraries/LibWeb/HTML/WindowOrWorkerGlobalScope.cpp @@ -12,8 +12,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -198,10 +200,49 @@ static ErrorOr> crop_to_the_source_rectangle_with_for // 6. Let output be the rectangle on the plane denoted by sourceRectangle. auto output = TRY(input->cropped(source_rectangle, Gfx::Color::Transparent)); - // FIXME: 7. Scale output to the size specified by outputWidth and outputHeight. The user agent should use the + // 7. Scale output to the size specified by outputWidth and outputHeight. The user agent should use the // value of the resizeQuality option to guide the choice of scaling algorithm. - (void)output_width; - (void)output_height; + struct ScalingPass { + Gfx::ScalingMode mode { Gfx::ScalingMode::None }; + int width { 0 }; + int height { 0 }; + }; + Vector scaling_passes; + switch (options.has_value() ? options->resize_quality : Bindings::ResizeQuality::Low) { + // NOTE: The spec mentions Bicubic or Lanczos scaling as higher quality options; however, Skia does not implement the latter, so for now we will use SkCubicResampler::Mitchell() for both medium and high + case Bindings::ResizeQuality::High: + // The "high" value indicates a preference for a high level of image interpolation quality. High-quality image interpolation may be more computationally expensive than lower settings. + case Bindings::ResizeQuality::Medium: + // The "medium" value indicates a preference for a medium level of image interpolation quality. + scaling_passes.append(ScalingPass { .mode = Gfx::ScalingMode::BoxSampling, .width = output_width, .height = output_height }); + break; + case Bindings::ResizeQuality::Low: + // The "low" value indicates a preference for a low level of image interpolation quality. Low-quality image interpolation may be more computationally efficient than higher settings. + scaling_passes.append(ScalingPass { .mode = Gfx::ScalingMode::BilinearBlend, .width = output_width, .height = output_height }); + break; + case Bindings::ResizeQuality::Pixelated: { + // The "pixelated" value indicates a preference for scaling the image to preserve the pixelation of the original as much as possible, with minor smoothing as necessary to avoid distorting the image when the target size is not a clean multiple of the original. + + // To implement "pixelated", for each axis independently, first determine the integer multiple of its natural size that puts it closest to the target size and is greater than zero. Scale it to this integer-multiple-size using nearest neighbor, + auto determine_closest_multiple = [](int const source_length, int const output_length) { + // NOTE: The previous cropping action would've failed if the source bitmap we are scaling had invalid lengths. Given the precondition that are lengths are always > 0, this integer division is safe from divide-by-zero and the quotient truncation is also safe as we will always round down + ASSERT(source_length > 0); + return output_length / source_length; + }; + auto const source_width = source_rectangle.width(); + auto const source_height = source_rectangle.height(); + auto const width_multiple = determine_closest_multiple(source_width, output_width); + auto const height_multiple = determine_closest_multiple(source_height, output_height); + if (width_multiple > 0 && height_multiple > 0) + scaling_passes.append(ScalingPass { .mode = Gfx::ScalingMode::NearestNeighbor, .width = source_width * width_multiple, .height = source_height * height_multiple }); + + // then scale it the rest of the way to the target size using bilinear interpolation. + scaling_passes.append(ScalingPass { .mode = Gfx::ScalingMode::BilinearBlend, .width = output_width, .height = output_height }); + } break; + } + for (ScalingPass& scaling_pass : scaling_passes) { + output = TRY(output->scaled(scaling_pass.width, scaling_pass.height, scaling_pass.mode)); + } // FIXME: 8. If the value of the imageOrientation member of options is "flipY", output must be flipped vertically, // disregarding any image orientation metadata of the source (such as EXIF metadata), if any. [EXIF] diff --git a/Tests/LibWeb/Text/expected/wpt-import/html/canvas/element/manual/imagebitmap/createImageBitmap-drawImage.txt b/Tests/LibWeb/Text/expected/wpt-import/html/canvas/element/manual/imagebitmap/createImageBitmap-drawImage.txt index ecbd0fba18c..3ff7dee89fa 100644 --- a/Tests/LibWeb/Text/expected/wpt-import/html/canvas/element/manual/imagebitmap/createImageBitmap-drawImage.txt +++ b/Tests/LibWeb/Text/expected/wpt-import/html/canvas/element/manual/imagebitmap/createImageBitmap-drawImage.txt @@ -2,12 +2,12 @@ Harness status: OK Found 55 tests -14 Pass -41 Fail +35 Pass +20 Fail Pass createImageBitmap from an HTMLCanvasElement, and drawImage on the created ImageBitmap -Fail createImageBitmap from an HTMLCanvasElement scaled down, and drawImage on the created ImageBitmap -Fail createImageBitmap from an HTMLCanvasElement scaled up, and drawImage on the created ImageBitmap -Fail createImageBitmap from an HTMLCanvasElement resized, and drawImage on the created ImageBitmap +Pass createImageBitmap from an HTMLCanvasElement scaled down, and drawImage on the created ImageBitmap +Pass createImageBitmap from an HTMLCanvasElement scaled up, and drawImage on the created ImageBitmap +Pass createImageBitmap from an HTMLCanvasElement resized, and drawImage on the created ImageBitmap Pass createImageBitmap from an HTMLCanvasElement with negative sw/sh, and drawImage on the created ImageBitmap Fail createImageBitmap from an HTMLVideoElement, and drawImage on the created ImageBitmap Fail createImageBitmap from an HTMLVideoElement scaled down, and drawImage on the created ImageBitmap @@ -20,24 +20,24 @@ Fail createImageBitmap from an HTMLVideoElement from a data URL scaled up, and d Fail createImageBitmap from an HTMLVideoElement from a data URL resized, and drawImage on the created ImageBitmap Fail createImageBitmap from an HTMLVideoElement from a data URL with negative sw/sh, and drawImage on the created ImageBitmap Pass createImageBitmap from a bitmap HTMLImageElement, and drawImage on the created ImageBitmap -Fail createImageBitmap from a bitmap HTMLImageElement scaled down, and drawImage on the created ImageBitmap -Fail createImageBitmap from a bitmap HTMLImageElement scaled up, and drawImage on the created ImageBitmap -Fail createImageBitmap from a bitmap HTMLImageElement resized, and drawImage on the created ImageBitmap +Pass createImageBitmap from a bitmap HTMLImageElement scaled down, and drawImage on the created ImageBitmap +Pass createImageBitmap from a bitmap HTMLImageElement scaled up, and drawImage on the created ImageBitmap +Pass createImageBitmap from a bitmap HTMLImageElement resized, and drawImage on the created ImageBitmap Pass createImageBitmap from a bitmap HTMLImageElement with negative sw/sh, and drawImage on the created ImageBitmap Pass createImageBitmap from a vector HTMLImageElement, and drawImage on the created ImageBitmap -Fail createImageBitmap from a vector HTMLImageElement scaled down, and drawImage on the created ImageBitmap -Fail createImageBitmap from a vector HTMLImageElement scaled up, and drawImage on the created ImageBitmap -Fail createImageBitmap from a vector HTMLImageElement resized, and drawImage on the created ImageBitmap +Pass createImageBitmap from a vector HTMLImageElement scaled down, and drawImage on the created ImageBitmap +Pass createImageBitmap from a vector HTMLImageElement scaled up, and drawImage on the created ImageBitmap +Pass createImageBitmap from a vector HTMLImageElement resized, and drawImage on the created ImageBitmap Pass createImageBitmap from a vector HTMLImageElement with negative sw/sh, and drawImage on the created ImageBitmap Pass createImageBitmap from a bitmap SVGImageElement, and drawImage on the created ImageBitmap -Fail createImageBitmap from a bitmap SVGImageElement scaled down, and drawImage on the created ImageBitmap -Fail createImageBitmap from a bitmap SVGImageElement scaled up, and drawImage on the created ImageBitmap -Fail createImageBitmap from a bitmap SVGImageElement resized, and drawImage on the created ImageBitmap +Pass createImageBitmap from a bitmap SVGImageElement scaled down, and drawImage on the created ImageBitmap +Pass createImageBitmap from a bitmap SVGImageElement scaled up, and drawImage on the created ImageBitmap +Pass createImageBitmap from a bitmap SVGImageElement resized, and drawImage on the created ImageBitmap Pass createImageBitmap from a bitmap SVGImageElement with negative sw/sh, and drawImage on the created ImageBitmap Pass createImageBitmap from a vector SVGImageElement, and drawImage on the created ImageBitmap -Fail createImageBitmap from a vector SVGImageElement scaled down, and drawImage on the created ImageBitmap -Fail createImageBitmap from a vector SVGImageElement scaled up, and drawImage on the created ImageBitmap -Fail createImageBitmap from a vector SVGImageElement resized, and drawImage on the created ImageBitmap +Pass createImageBitmap from a vector SVGImageElement scaled down, and drawImage on the created ImageBitmap +Pass createImageBitmap from a vector SVGImageElement scaled up, and drawImage on the created ImageBitmap +Pass createImageBitmap from a vector SVGImageElement resized, and drawImage on the created ImageBitmap Pass createImageBitmap from a vector SVGImageElement with negative sw/sh, and drawImage on the created ImageBitmap Fail createImageBitmap from an OffscreenCanvas, and drawImage on the created ImageBitmap Fail createImageBitmap from an OffscreenCanvas scaled down, and drawImage on the created ImageBitmap @@ -45,9 +45,9 @@ Fail createImageBitmap from an OffscreenCanvas scaled up, and drawImage on the c Fail createImageBitmap from an OffscreenCanvas resized, and drawImage on the created ImageBitmap Fail createImageBitmap from an OffscreenCanvas with negative sw/sh, and drawImage on the created ImageBitmap Pass createImageBitmap from an ImageData, and drawImage on the created ImageBitmap -Fail createImageBitmap from an ImageData scaled down, and drawImage on the created ImageBitmap -Fail createImageBitmap from an ImageData scaled up, and drawImage on the created ImageBitmap -Fail createImageBitmap from an ImageData resized, and drawImage on the created ImageBitmap +Pass createImageBitmap from an ImageData scaled down, and drawImage on the created ImageBitmap +Pass createImageBitmap from an ImageData scaled up, and drawImage on the created ImageBitmap +Pass createImageBitmap from an ImageData resized, and drawImage on the created ImageBitmap Pass createImageBitmap from an ImageData with negative sw/sh, and drawImage on the created ImageBitmap Fail createImageBitmap from an ImageBitmap, and drawImage on the created ImageBitmap Fail createImageBitmap from an ImageBitmap scaled down, and drawImage on the created ImageBitmap @@ -55,7 +55,7 @@ Fail createImageBitmap from an ImageBitmap scaled up, and drawImage on the creat Fail createImageBitmap from an ImageBitmap resized, and drawImage on the created ImageBitmap Fail createImageBitmap from an ImageBitmap with negative sw/sh, and drawImage on the created ImageBitmap Pass createImageBitmap from a Blob, and drawImage on the created ImageBitmap -Fail createImageBitmap from a Blob scaled down, and drawImage on the created ImageBitmap -Fail createImageBitmap from a Blob scaled up, and drawImage on the created ImageBitmap -Fail createImageBitmap from a Blob resized, and drawImage on the created ImageBitmap +Pass createImageBitmap from a Blob scaled down, and drawImage on the created ImageBitmap +Pass createImageBitmap from a Blob scaled up, and drawImage on the created ImageBitmap +Pass createImageBitmap from a Blob resized, and drawImage on the created ImageBitmap Pass createImageBitmap from a Blob with negative sw/sh, and drawImage on the created ImageBitmap \ No newline at end of file From d349e913394687699f5ae4eb7d30daa4c38c2a2d Mon Sep 17 00:00:00 2001 From: Jelle Raaijmakers Date: Tue, 14 Oct 2025 09:42:43 +0200 Subject: [PATCH 14/27] CI: Inject COMMIT file into archives when missing This allows us to run js-benchmarks against older commits and have the workflow correctly identify the commit used to build the binaries. In order to actually build commits where the wasm binary was not yet built, we have to account for missing archives as well. --- .github/workflows/js-and-wasm-artifacts.yml | 37 ++++++++++++++++----- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/.github/workflows/js-and-wasm-artifacts.yml b/.github/workflows/js-and-wasm-artifacts.yml index 77a33e56f5b..2109a8e5997 100644 --- a/.github/workflows/js-and-wasm-artifacts.yml +++ b/.github/workflows/js-and-wasm-artifacts.yml @@ -49,6 +49,12 @@ jobs: with: ref: ${{ inputs.reference_to_build }} + - name: 'Determine build commit hash' + id: build-commit + shell: bash + run: | + echo "sha=$(git rev-parse HEAD)" >> "${GITHUB_OUTPUT}" + - name: "Set up environment" uses: ./.github/actions/setup with: @@ -102,6 +108,17 @@ jobs: run: | cpack + # Inject the COMMIT file for older builds (before commit 5c5de0e30e04). + for package in ladybird-*.tar.gz; do + if ! tar -tzf "${package}" | grep -qx COMMIT; then + echo "${{ steps.build-commit.outputs.sha }}" > COMMIT + gunzip "${package}" + tar --append --file="${package%.gz}" COMMIT + gzip "${package%.gz}" + rm COMMIT + fi + done + - name: Save Caches uses: ./.github/actions/cache-save with: @@ -115,23 +132,27 @@ jobs: - name: Sanity-check the js repl shell: bash run: | - set -e - tar -xvzf Build/distribution/ladybird-js-${{ matrix.package_type }}.tar.gz - ./bin/js -c "console.log('Hello, World\!');" > js-repl-out.txt - if ! grep -q "\"Hello, World\!\"" js-repl-out.txt; then + path="Build/distribution/ladybird-js-${{ matrix.package_type }}.tar.gz" + if [ -f "${path}" ]; then + tar -xvzf "${path}" + bin/js -c "console.log('Hello, World\!');" > js-repl-out.txt + if ! grep -q "\"Hello, World\!\"" js-repl-out.txt; then echo "Sanity check failed: \"Hello, World\!\" not found in output." exit 1 + fi fi - name: Sanity-check the wasm repl shell: bash run: | - set -e - tar -xvzf Build/distribution/ladybird-wasm-${{ matrix.package_type }}.tar.gz - ./bin/wasm -e run_sanity_check -w ${{ github.workspace }}/Libraries/LibWasm/Tests/CI/ci-sanity-check.wasm > wasm-repl-out.txt - if ! grep -q "Hello, World\!" wasm-repl-out.txt; then + path="Build/distribution/ladybird-wasm-${{ matrix.package_type }}.tar.gz" + if [ -f "${path}" ]; then + tar -xvzf "${path}" + bin/wasm -e run_sanity_check -w ${{ github.workspace }}/Libraries/LibWasm/Tests/CI/ci-sanity-check.wasm > wasm-repl-out.txt + if ! grep -q "Hello, World\!" wasm-repl-out.txt; then echo "Sanity check failed: Hello, World\! not found in output." exit 1 + fi fi - name: Upload js package From 62e52640d068d6975f72ca2b7a929eeb1f3d1fe3 Mon Sep 17 00:00:00 2001 From: Timothy Flynn Date: Mon, 6 Oct 2025 09:46:13 -0400 Subject: [PATCH 15/27] LibCore: Add a standard path for cache data --- Libraries/LibCore/StandardPaths.cpp | 21 +++++++++++++++++++++ Libraries/LibCore/StandardPaths.h | 1 + 2 files changed, 22 insertions(+) diff --git a/Libraries/LibCore/StandardPaths.cpp b/Libraries/LibCore/StandardPaths.cpp index cab1d2c204e..911061f3f61 100644 --- a/Libraries/LibCore/StandardPaths.cpp +++ b/Libraries/LibCore/StandardPaths.cpp @@ -129,6 +129,27 @@ ByteString StandardPaths::videos_directory() return LexicalPath::canonicalized_path(builder.to_byte_string()); } +ByteString StandardPaths::cache_directory() +{ +#if defined(AK_OS_WINDOWS) || defined(AK_OS_HAIKU) + return user_data_directory(); +#else + if (auto cache_directory = get_environment_if_not_empty("XDG_CACHE_HOME"sv); cache_directory.has_value()) + return LexicalPath::canonicalized_path(*cache_directory); + + StringBuilder builder; + builder.append(home_directory()); + +# if defined(AK_OS_MACOS) + builder.append("/Library/Caches"sv); +# else + builder.append("/.cache"sv); +# endif + + return LexicalPath::canonicalized_path(builder.to_byte_string()); +#endif +} + ByteString StandardPaths::config_directory() { StringBuilder builder; diff --git a/Libraries/LibCore/StandardPaths.h b/Libraries/LibCore/StandardPaths.h index 028f860027a..30fa4de8c1a 100644 --- a/Libraries/LibCore/StandardPaths.h +++ b/Libraries/LibCore/StandardPaths.h @@ -22,6 +22,7 @@ public: static ByteString pictures_directory(); static ByteString videos_directory(); static ByteString tempfile_directory(); + static ByteString cache_directory(); static ByteString config_directory(); static ByteString user_data_directory(); static Vector system_data_directories(); From e433dee5431c86f2c224b6f5e864e0cdbefbaa0b Mon Sep 17 00:00:00 2001 From: Timothy Flynn Date: Wed, 8 Oct 2025 08:01:14 -0400 Subject: [PATCH 16/27] LibCore: Add a system wrapper to pipe a file This uses splice on Linux and mmap+write elsewhere to transfer a file to a pipe. Note we cannot use sendfile because (at least on macOS) the receiving fd must be a socket. --- Libraries/LibCore/System.cpp | 23 +++++++++++++++++++++++ Libraries/LibCore/System.h | 2 ++ Libraries/LibCore/SystemWindows.cpp | 10 ++++++++++ 3 files changed, 35 insertions(+) diff --git a/Libraries/LibCore/System.cpp b/Libraries/LibCore/System.cpp index 59a2b9ec269..c8ed6b3cbc2 100644 --- a/Libraries/LibCore/System.cpp +++ b/Libraries/LibCore/System.cpp @@ -870,4 +870,27 @@ ErrorOr set_close_on_exec(int fd, bool enabled) return {}; } +ErrorOr transfer_file_through_pipe(int source_fd, int target_fd, size_t source_offset, size_t source_length) +{ +#if defined(AK_OS_LINUX) + auto sent = ::splice(source_fd, reinterpret_cast(&source_offset), target_fd, nullptr, source_length, SPLICE_F_MOVE | SPLICE_F_NONBLOCK); + if (sent < 0) + return Error::from_syscall("send_file_to_pipe"sv, errno); + return sent; +#else + static auto page_size = PAGE_SIZE; + + // mmap requires the offset to be page-aligned, so we must handle that here. + auto aligned_source_offset = (source_offset / page_size) * page_size; + auto offset_adjustment = source_offset - aligned_source_offset; + auto mapped_source_length = source_length + offset_adjustment; + + // FIXME: We could use MappedFile here if we update it to support offsets and not auto-close the source fd. + auto* mapped = TRY(mmap(nullptr, mapped_source_length, PROT_READ, MAP_SHARED, source_fd, aligned_source_offset)); + ScopeGuard guard { [&]() { (void)munmap(mapped, mapped_source_length); } }; + + return TRY(write(target_fd, { static_cast(mapped) + offset_adjustment, source_length })); +#endif +} + } diff --git a/Libraries/LibCore/System.h b/Libraries/LibCore/System.h index cc84c5eedb5..8ebf7603ba9 100644 --- a/Libraries/LibCore/System.h +++ b/Libraries/LibCore/System.h @@ -190,4 +190,6 @@ bool is_socket(int fd); ErrorOr sleep_ms(u32 milliseconds); ErrorOr set_close_on_exec(int fd, bool enabled); +ErrorOr transfer_file_through_pipe(int source_fd, int target_fd, size_t source_offset, size_t source_length); + } diff --git a/Libraries/LibCore/SystemWindows.cpp b/Libraries/LibCore/SystemWindows.cpp index 289551730c5..7a0eda67b03 100644 --- a/Libraries/LibCore/SystemWindows.cpp +++ b/Libraries/LibCore/SystemWindows.cpp @@ -403,4 +403,14 @@ ErrorOr kill(pid_t pid, int signal) return {}; } +ErrorOr transfer_file_through_pipe(int source_fd, int target_fd, size_t source_offset, size_t source_length) +{ + (void)source_fd; + (void)target_fd; + (void)source_offset; + (void)source_length; + + return Error::from_string_literal("FIXME: Implement System::transfer_file_through_pipe on Windows (for HTTP disk cache)"); +} + } From 187d02c45dd81cecab60936ab585647a8ceb9301 Mon Sep 17 00:00:00 2001 From: Timothy Flynn Date: Tue, 7 Oct 2025 14:35:22 -0400 Subject: [PATCH 17/27] LibDatabase+LibWebView: Extract our SQLite wrapper to its own library It currently lives in LibWebView as it was only used for cookies and local storage, both of which are managed in the UI process. Let's move it to its own library now to allow other processes to use it, without having to depend on LibWebView (and therefore LibWeb). --- Libraries/CMakeLists.txt | 1 + Libraries/LibDatabase/CMakeLists.txt | 8 +++++ .../{LibWebView => LibDatabase}/Database.cpp | 34 ++++++++----------- .../{LibWebView => LibDatabase}/Database.h | 11 +++--- Libraries/LibDatabase/Forward.h | 18 ++++++++++ Libraries/LibWebView/Application.cpp | 11 +++--- Libraries/LibWebView/Application.h | 3 +- Libraries/LibWebView/CMakeLists.txt | 6 +--- Libraries/LibWebView/CookieJar.cpp | 5 +-- Libraries/LibWebView/CookieJar.h | 6 ++-- Libraries/LibWebView/Forward.h | 1 - Libraries/LibWebView/StorageJar.cpp | 3 +- Libraries/LibWebView/StorageJar.h | 6 ++-- 13 files changed, 68 insertions(+), 45 deletions(-) create mode 100644 Libraries/LibDatabase/CMakeLists.txt rename Libraries/{LibWebView => LibDatabase}/Database.cpp (80%) rename Libraries/{LibWebView => LibDatabase}/Database.h (83%) create mode 100644 Libraries/LibDatabase/Forward.h diff --git a/Libraries/CMakeLists.txt b/Libraries/CMakeLists.txt index f823ce69124..76ce2735c2d 100644 --- a/Libraries/CMakeLists.txt +++ b/Libraries/CMakeLists.txt @@ -1,5 +1,6 @@ add_subdirectory(LibCompress) add_subdirectory(LibCrypto) +add_subdirectory(LibDatabase) add_subdirectory(LibDiff) add_subdirectory(LibDNS) add_subdirectory(LibGC) diff --git a/Libraries/LibDatabase/CMakeLists.txt b/Libraries/LibDatabase/CMakeLists.txt new file mode 100644 index 00000000000..d30f6cec0b6 --- /dev/null +++ b/Libraries/LibDatabase/CMakeLists.txt @@ -0,0 +1,8 @@ +set(SOURCES + Database.cpp +) + +find_package(SQLite3 REQUIRED) + +ladybird_lib(LibDatabase database EXPLICIT_SYMBOL_EXPORT) +target_link_libraries(LibDatabase PRIVATE LibCore SQLite::SQLite3) diff --git a/Libraries/LibWebView/Database.cpp b/Libraries/LibDatabase/Database.cpp similarity index 80% rename from Libraries/LibWebView/Database.cpp rename to Libraries/LibDatabase/Database.cpp index e78174a38ba..de70443ac0d 100644 --- a/Libraries/LibWebView/Database.cpp +++ b/Libraries/LibDatabase/Database.cpp @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022-2024, Tim Flynn + * Copyright (c) 2022-2025, Tim Flynn * * SPDX-License-Identifier: BSD-2-Clause */ @@ -8,12 +8,11 @@ #include #include #include -#include -#include +#include #include -namespace WebView { +namespace Database { static constexpr StringView sql_error(int error_code) { @@ -39,13 +38,10 @@ static constexpr StringView sql_error(int error_code) } \ }) -ErrorOr> Database::create() +ErrorOr> Database::create(ByteString const& directory, StringView name) { - // FIXME: Move this to a generic "Ladybird data directory" helper. - auto database_path = ByteString::formatted("{}/Ladybird", Core::StandardPaths::user_data_directory()); - TRY(Core::Directory::create(database_path, Core::Directory::CreateDirectories::Yes)); - - auto database_file = ByteString::formatted("{}/Ladybird.db", database_path); + TRY(Core::Directory::create(directory, Core::Directory::CreateDirectories::Yes)); + auto database_file = ByteString::formatted("{}/{}.db", directory, name); sqlite3* m_database { nullptr }; SQL_TRY(sqlite3_open(database_file.characters(), &m_database)); @@ -67,7 +63,7 @@ Database::~Database() sqlite3_close(m_database); } -ErrorOr Database::prepare_statement(StringView statement) +ErrorOr Database::prepare_statement(StringView statement) { sqlite3_stmt* prepared_statement { nullptr }; SQL_TRY(sqlite3_prepare_v2(m_database, statement.characters_without_null_termination(), static_cast(statement.length()), &prepared_statement, nullptr)); @@ -119,10 +115,10 @@ void Database::apply_placeholder(StatementID statement_id, int index, ValueType } } -template void Database::apply_placeholder(StatementID, int, String const&); -template void Database::apply_placeholder(StatementID, int, UnixDateTime const&); -template void Database::apply_placeholder(StatementID, int, int const&); -template void Database::apply_placeholder(StatementID, int, bool const&); +template DATABASE_API void Database::apply_placeholder(StatementID, int, String const&); +template DATABASE_API void Database::apply_placeholder(StatementID, int, UnixDateTime const&); +template DATABASE_API void Database::apply_placeholder(StatementID, int, int const&); +template DATABASE_API void Database::apply_placeholder(StatementID, int, bool const&); template ValueType Database::result_column(StatementID statement_id, int column) @@ -144,9 +140,9 @@ ValueType Database::result_column(StatementID statement_id, int column) VERIFY_NOT_REACHED(); } -template String Database::result_column(StatementID, int); -template UnixDateTime Database::result_column(StatementID, int); -template int Database::result_column(StatementID, int); -template bool Database::result_column(StatementID, int); +template DATABASE_API String Database::result_column(StatementID, int); +template DATABASE_API UnixDateTime Database::result_column(StatementID, int); +template DATABASE_API int Database::result_column(StatementID, int); +template DATABASE_API bool Database::result_column(StatementID, int); } diff --git a/Libraries/LibWebView/Database.h b/Libraries/LibDatabase/Database.h similarity index 83% rename from Libraries/LibWebView/Database.h rename to Libraries/LibDatabase/Database.h index 044211a5b45..0c105e6628e 100644 --- a/Libraries/LibWebView/Database.h +++ b/Libraries/LibDatabase/Database.h @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022-2024, Tim Flynn + * Copyright (c) 2022-2025, Tim Flynn * Copyright (c) 2023, Jelle Raaijmakers * * SPDX-License-Identifier: BSD-2-Clause @@ -13,19 +13,18 @@ #include #include #include -#include +#include struct sqlite3; struct sqlite3_stmt; -namespace WebView { +namespace Database { -class WEBVIEW_API Database : public RefCounted { +class DATABASE_API Database : public RefCounted { public: - static ErrorOr> create(); + static ErrorOr> create(ByteString const& directory, StringView name); ~Database(); - using StatementID = size_t; using OnResult = Function; ErrorOr prepare_statement(StringView statement); diff --git a/Libraries/LibDatabase/Forward.h b/Libraries/LibDatabase/Forward.h new file mode 100644 index 00000000000..a5afc01bd8a --- /dev/null +++ b/Libraries/LibDatabase/Forward.h @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include + +namespace Database { + +class Database; + +using StatementID = size_t; + +} diff --git a/Libraries/LibWebView/Application.cpp b/Libraries/LibWebView/Application.cpp index da005ddbb82..6248832e515 100644 --- a/Libraries/LibWebView/Application.cpp +++ b/Libraries/LibWebView/Application.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -17,7 +18,6 @@ #include #include #include -#include #include #include #include @@ -347,9 +347,12 @@ ErrorOr Application::launch_services() }; if (m_browser_options.disable_sql_database == DisableSQLDatabase::No) { - m_database = Database::create().release_value_but_fixme_should_propagate_errors(); - m_cookie_jar = CookieJar::create(*m_database).release_value_but_fixme_should_propagate_errors(); - m_storage_jar = StorageJar::create(*m_database).release_value_but_fixme_should_propagate_errors(); + // FIXME: Move this to a generic "Ladybird data directory" helper. + auto database_path = ByteString::formatted("{}/Ladybird", Core::StandardPaths::user_data_directory()); + + m_database = TRY(Database::Database::create(database_path, "Ladybird"sv)); + m_cookie_jar = TRY(CookieJar::create(*m_database)); + m_storage_jar = TRY(StorageJar::create(*m_database)); } else { m_cookie_jar = CookieJar::create(); m_storage_jar = StorageJar::create(); diff --git a/Libraries/LibWebView/Application.h b/Libraries/LibWebView/Application.h index d0a13269dfa..75c53c9fae7 100644 --- a/Libraries/LibWebView/Application.h +++ b/Libraries/LibWebView/Application.h @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -181,7 +182,7 @@ private: RefPtr m_spare_web_content_process; bool m_has_queued_task_to_launch_spare_web_content_process { false }; - RefPtr m_database; + RefPtr m_database; OwnPtr m_cookie_jar; OwnPtr m_storage_jar; diff --git a/Libraries/LibWebView/CMakeLists.txt b/Libraries/LibWebView/CMakeLists.txt index 532347e2f47..9503d8f6cd1 100644 --- a/Libraries/LibWebView/CMakeLists.txt +++ b/Libraries/LibWebView/CMakeLists.txt @@ -7,7 +7,6 @@ set(SOURCES BrowserProcess.cpp ConsoleOutput.cpp CookieJar.cpp - Database.cpp DOMNodeProperties.cpp HeadlessWebView.cpp HelperProcess.cpp @@ -70,16 +69,13 @@ set(GENERATED_SOURCES ) ladybird_lib(LibWebView webview EXPLICIT_SYMBOL_EXPORT) -target_link_libraries(LibWebView PRIVATE LibCore LibDevTools LibFileSystem LibGfx LibImageDecoderClient LibIPC LibRequests LibJS LibWeb LibUnicode LibURL LibSyntax LibTextCodec) +target_link_libraries(LibWebView PRIVATE LibCore LibDatabase LibDevTools LibFileSystem LibGfx LibImageDecoderClient LibIPC LibRequests LibJS LibWeb LibUnicode LibURL LibSyntax LibTextCodec) if (APPLE) target_link_libraries(LibWebView PRIVATE LibThreading) endif() # Third-party -find_package(SQLite3 REQUIRED) -target_link_libraries(LibWebView PRIVATE SQLite::SQLite3) - if (HAS_FONTCONFIG) target_link_libraries(LibWebView PRIVATE Fontconfig::Fontconfig) endif() diff --git a/Libraries/LibWebView/CookieJar.cpp b/Libraries/LibWebView/CookieJar.cpp index 7b7e4bebc0f..3972273586e 100644 --- a/Libraries/LibWebView/CookieJar.cpp +++ b/Libraries/LibWebView/CookieJar.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -20,7 +21,7 @@ namespace WebView { static constexpr auto DATABASE_SYNCHRONIZATION_TIMER = AK::Duration::from_seconds(30); -ErrorOr> CookieJar::create(Database& database) +ErrorOr> CookieJar::create(Database::Database& database) { Statements statements {}; @@ -665,7 +666,7 @@ void CookieJar::PersistedStorage::insert_cookie(Web::Cookie::Cookie const& cooki cookie.persistent); } -static Web::Cookie::Cookie parse_cookie(Database& database, Database::StatementID statement_id) +static Web::Cookie::Cookie parse_cookie(Database::Database& database, Database::StatementID statement_id) { int column = 0; auto convert_text = [&](auto& field) { field = database.result_column(statement_id, column++); }; diff --git a/Libraries/LibWebView/CookieJar.h b/Libraries/LibWebView/CookieJar.h index 6745b132b5e..2aa79c3c346 100644 --- a/Libraries/LibWebView/CookieJar.h +++ b/Libraries/LibWebView/CookieJar.h @@ -13,10 +13,10 @@ #include #include #include +#include #include #include #include -#include #include namespace WebView { @@ -76,13 +76,13 @@ class WEBVIEW_API CookieJar { void insert_cookie(Web::Cookie::Cookie const& cookie); TransientStorage::Cookies select_all_cookies(); - Database& database; + Database::Database& database; Statements statements; RefPtr synchronization_timer {}; }; public: - static ErrorOr> create(Database&); + static ErrorOr> create(Database::Database&); static NonnullOwnPtr create(); ~CookieJar(); diff --git a/Libraries/LibWebView/Forward.h b/Libraries/LibWebView/Forward.h index 1e5f7bad587..4507d42117a 100644 --- a/Libraries/LibWebView/Forward.h +++ b/Libraries/LibWebView/Forward.h @@ -16,7 +16,6 @@ class Action; class Application; class Autocomplete; class CookieJar; -class Database; class Menu; class OutOfProcessWebView; class ProcessManager; diff --git a/Libraries/LibWebView/StorageJar.cpp b/Libraries/LibWebView/StorageJar.cpp index c11194b0882..e0b22e0672e 100644 --- a/Libraries/LibWebView/StorageJar.cpp +++ b/Libraries/LibWebView/StorageJar.cpp @@ -6,6 +6,7 @@ #include #include +#include #include namespace WebView { @@ -13,7 +14,7 @@ namespace WebView { // Quota size is specified in https://storage.spec.whatwg.org/#registered-storage-endpoints static constexpr size_t LOCAL_STORAGE_QUOTA = 5 * MiB; -ErrorOr> StorageJar::create(Database& database) +ErrorOr> StorageJar::create(Database::Database& database) { Statements statements {}; diff --git a/Libraries/LibWebView/StorageJar.h b/Libraries/LibWebView/StorageJar.h index 39b835ecd5a..0baae08f772 100644 --- a/Libraries/LibWebView/StorageJar.h +++ b/Libraries/LibWebView/StorageJar.h @@ -9,8 +9,8 @@ #include #include #include +#include #include -#include #include #include @@ -31,7 +31,7 @@ class WEBVIEW_API StorageJar { AK_MAKE_NONMOVABLE(StorageJar); public: - static ErrorOr> create(Database&); + static ErrorOr> create(Database::Database&); static NonnullOwnPtr create(); ~StorageJar(); @@ -71,7 +71,7 @@ private: void clear(StorageEndpointType storage_endpoint, String const& storage_key); Vector get_keys(StorageEndpointType storage_endpoint, String const& storage_key); - Database& database; + Database::Database& database; Statements statements; }; From 411aed96abd80ee0deb6b96fea62d0b8be0c782b Mon Sep 17 00:00:00 2001 From: Timothy Flynn Date: Tue, 7 Oct 2025 15:39:00 -0400 Subject: [PATCH 18/27] LibDatabase: Support all C++ integral types in SQL storage --- Libraries/LibDatabase/Database.cpp | 59 ++++++++++++++++++++---------- 1 file changed, 39 insertions(+), 20 deletions(-) diff --git a/Libraries/LibDatabase/Database.cpp b/Libraries/LibDatabase/Database.cpp index de70443ac0d..faed11b2022 100644 --- a/Libraries/LibDatabase/Database.cpp +++ b/Libraries/LibDatabase/Database.cpp @@ -38,6 +38,21 @@ static constexpr StringView sql_error(int error_code) } \ }) +#define ENUMERATE_SQL_TYPES \ + __ENUMERATE_TYPE(String) \ + __ENUMERATE_TYPE(UnixDateTime) \ + __ENUMERATE_TYPE(i8) \ + __ENUMERATE_TYPE(i16) \ + __ENUMERATE_TYPE(i32) \ + __ENUMERATE_TYPE(long) \ + __ENUMERATE_TYPE(long long) \ + __ENUMERATE_TYPE(u8) \ + __ENUMERATE_TYPE(u16) \ + __ENUMERATE_TYPE(u32) \ + __ENUMERATE_TYPE(unsigned long) \ + __ENUMERATE_TYPE(unsigned long long) \ + __ENUMERATE_TYPE(bool) + ErrorOr> Database::create(ByteString const& directory, StringView name) { TRY(Core::Directory::create(directory, Core::Directory::CreateDirectories::Yes)); @@ -107,18 +122,21 @@ void Database::apply_placeholder(StatementID statement_id, int index, ValueType StringView string { value }; SQL_MUST(sqlite3_bind_text(statement, index, string.characters_without_null_termination(), static_cast(string.length()), SQLITE_TRANSIENT)); } else if constexpr (IsSame) { - SQL_MUST(sqlite3_bind_int64(statement, index, value.offset_to_epoch().to_milliseconds())); - } else if constexpr (IsSame) { - SQL_MUST(sqlite3_bind_int(statement, index, value)); - } else if constexpr (IsSame) { - SQL_MUST(sqlite3_bind_int(statement, index, static_cast(value))); + apply_placeholder(statement_id, index, value.offset_to_epoch().to_milliseconds()); + } else if constexpr (IsIntegral) { + if constexpr (sizeof(ValueType) <= sizeof(int)) + SQL_MUST(sqlite3_bind_int(statement, index, static_cast(value))); + else + SQL_MUST(sqlite3_bind_int64(statement, index, static_cast(value))); + } else { + static_assert(DependentFalse); } } -template DATABASE_API void Database::apply_placeholder(StatementID, int, String const&); -template DATABASE_API void Database::apply_placeholder(StatementID, int, UnixDateTime const&); -template DATABASE_API void Database::apply_placeholder(StatementID, int, int const&); -template DATABASE_API void Database::apply_placeholder(StatementID, int, bool const&); +#define __ENUMERATE_TYPE(type) \ + template DATABASE_API void Database::apply_placeholder(StatementID, int, type const&); +ENUMERATE_SQL_TYPES +#undef __ENUMERATE_TYPE template ValueType Database::result_column(StatementID statement_id, int column) @@ -129,20 +147,21 @@ ValueType Database::result_column(StatementID statement_id, int column) auto const* text = reinterpret_cast(sqlite3_column_text(statement, column)); return MUST(String::from_utf8(StringView { text, strlen(text) })); } else if constexpr (IsSame) { - auto milliseconds = sqlite3_column_int64(statement, column); + auto milliseconds = result_column(statement_id, column); return UnixDateTime::from_milliseconds_since_epoch(milliseconds); - } else if constexpr (IsSame) { - return sqlite3_column_int(statement, column); - } else if constexpr (IsSame) { - return static_cast(sqlite3_column_int(statement, column)); + } else if constexpr (IsIntegral) { + if constexpr (sizeof(ValueType) <= sizeof(int)) + return static_cast(sqlite3_column_int(statement, column)); + else + return static_cast(sqlite3_column_int64(statement, column)); + } else { + static_assert(DependentFalse); } - - VERIFY_NOT_REACHED(); } -template DATABASE_API String Database::result_column(StatementID, int); -template DATABASE_API UnixDateTime Database::result_column(StatementID, int); -template DATABASE_API int Database::result_column(StatementID, int); -template DATABASE_API bool Database::result_column(StatementID, int); +#define __ENUMERATE_TYPE(type) \ + template DATABASE_API type Database::result_column(StatementID, int); +ENUMERATE_SQL_TYPES +#undef __ENUMERATE_TYPE } From 3516a2344f5503839ceae92efb5856128c7366dd Mon Sep 17 00:00:00 2001 From: Timothy Flynn Date: Tue, 7 Oct 2025 19:59:21 -0400 Subject: [PATCH 19/27] LibRequests+RequestServer: Begin implementing an HTTP disk cache This adds a disk cache for HTTP responses received from the network. For now, we take a rather conservative approach to caching. We don't cache a response until we're 100% sure it is cacheable (there are heuristics we can implement in the future based on the absence of specific headers). The cache is broken into 2 categories of files: 1. An index file. This is a SQL database containing metadata about each cache entry (URL, timestamps, etc.). 2. Cache files. Each cached response is in its own file. The file is an amalgamation of all info needed to reconstruct an HTTP response. This includes the status code, headers, body, etc. A cache entry is created once we receive the headers for a response. The index, however, is not updated at this point. We stream the body into the cache entry as it is received. Once we've successfully cached the entire body, we create an index entry in the database. If any of these steps failed along the way, the cache entry is removed and the index is left untouched. Subsequent requests are checked for cache hits from the index. If a hit is found, we read just enough of the cache entry to inform WebContent of the status code and headers. The body of the response is piped to WC via syscalls, such that the transfer happens entirely in the kernel; no need to allocate the memory for the body in userspace (WC still allocates a buffer to hold the data, of course). If an error occurs while piping the body, we currently error out the request. There is a FIXME to switch to a network request. Cache hits are also validated for freshness before they are used. If a response has expired, we remove it and its index entry, and proceed with a network request. --- Libraries/LibRequests/NetworkError.h | 3 + Services/RequestServer/CMakeLists.txt | 6 +- Services/RequestServer/Cache/CacheEntry.cpp | 346 ++++++++++++++++++ Services/RequestServer/Cache/CacheEntry.h | 130 +++++++ Services/RequestServer/Cache/CacheIndex.cpp | 99 +++++ Services/RequestServer/Cache/CacheIndex.h | 57 +++ Services/RequestServer/Cache/DiskCache.cpp | 99 +++++ Services/RequestServer/Cache/DiskCache.h | 45 +++ Services/RequestServer/Cache/Utilities.cpp | 220 +++++++++++ Services/RequestServer/Cache/Utilities.h | 27 ++ .../RequestServer/ConnectionFromClient.cpp | 61 ++- Services/RequestServer/Forward.h | 17 + Services/RequestServer/main.cpp | 11 + 13 files changed, 1114 insertions(+), 7 deletions(-) create mode 100644 Services/RequestServer/Cache/CacheEntry.cpp create mode 100644 Services/RequestServer/Cache/CacheEntry.h create mode 100644 Services/RequestServer/Cache/CacheIndex.cpp create mode 100644 Services/RequestServer/Cache/CacheIndex.h create mode 100644 Services/RequestServer/Cache/DiskCache.cpp create mode 100644 Services/RequestServer/Cache/DiskCache.h create mode 100644 Services/RequestServer/Cache/Utilities.cpp create mode 100644 Services/RequestServer/Cache/Utilities.h create mode 100644 Services/RequestServer/Forward.h diff --git a/Libraries/LibRequests/NetworkError.h b/Libraries/LibRequests/NetworkError.h index 5250d99e318..d2ca83bbe5c 100644 --- a/Libraries/LibRequests/NetworkError.h +++ b/Libraries/LibRequests/NetworkError.h @@ -21,6 +21,7 @@ enum class NetworkError { MalformedUrl, InvalidContentEncoding, RequestServerDied, + CacheReadFailed, Unknown, }; @@ -47,6 +48,8 @@ constexpr StringView network_error_to_string(NetworkError network_error) return "Response could not be decoded with its Content-Encoding"sv; case NetworkError::RequestServerDied: return "RequestServer is currently unavailable"sv; + case NetworkError::CacheReadFailed: + return "RequestServer encountered an error reading a cached HTTP response"sv; case NetworkError::Unknown: return "An unexpected network error occurred"sv; } diff --git a/Services/RequestServer/CMakeLists.txt b/Services/RequestServer/CMakeLists.txt index 59d862fed7d..dc23582c626 100644 --- a/Services/RequestServer/CMakeLists.txt +++ b/Services/RequestServer/CMakeLists.txt @@ -3,6 +3,10 @@ set(CMAKE_AUTORCC OFF) set(CMAKE_AUTOUIC OFF) set(SOURCES + Cache/CacheEntry.cpp + Cache/CacheIndex.cpp + Cache/DiskCache.cpp + Cache/Utilities.cpp ConnectionFromClient.cpp WebSocketImplCurl.cpp ) @@ -33,7 +37,7 @@ target_include_directories(requestserverservice PRIVATE ${CMAKE_CURRENT_BINARY_D target_include_directories(requestserverservice PRIVATE ${LADYBIRD_SOURCE_DIR}/Services/) target_link_libraries(RequestServer PRIVATE requestserverservice) -target_link_libraries(requestserverservice PUBLIC LibCore LibDNS LibMain LibCrypto LibFileSystem LibIPC LibMain LibTLS LibWebSocket LibURL LibTextCodec LibThreading CURL::libcurl) +target_link_libraries(requestserverservice PUBLIC LibCore LibDatabase LibDNS LibCrypto LibFileSystem LibIPC LibMain LibTLS LibWebSocket LibURL LibTextCodec LibThreading CURL::libcurl) target_link_libraries(requestserverservice PRIVATE OpenSSL::Crypto OpenSSL::SSL) if (${CMAKE_SYSTEM_NAME} MATCHES "SunOS") diff --git a/Services/RequestServer/Cache/CacheEntry.cpp b/Services/RequestServer/Cache/CacheEntry.cpp new file mode 100644 index 00000000000..98ce603fe1d --- /dev/null +++ b/Services/RequestServer/Cache/CacheEntry.cpp @@ -0,0 +1,346 @@ +/* + * Copyright (c) 2025, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace RequestServer { + +static LexicalPath path_for_cache_key(LexicalPath const& cache_directory, u64 cache_key) +{ + return cache_directory.append(MUST(String::formatted("{:016x}", cache_key))); +} + +ErrorOr CacheHeader::read_from_stream(Stream& stream) +{ + CacheHeader header; + header.magic = TRY(stream.read_value()); + header.version = TRY(stream.read_value()); + header.url_size = TRY(stream.read_value()); + header.url_hash = TRY(stream.read_value()); + header.status_code = TRY(stream.read_value()); + header.reason_phrase_size = TRY(stream.read_value()); + header.reason_phrase_hash = TRY(stream.read_value()); + header.headers_size = TRY(stream.read_value()); + header.headers_hash = TRY(stream.read_value()); + return header; +} + +ErrorOr CacheHeader::write_to_stream(Stream& stream) const +{ + TRY(stream.write_value(magic)); + TRY(stream.write_value(version)); + TRY(stream.write_value(url_size)); + TRY(stream.write_value(url_hash)); + TRY(stream.write_value(status_code)); + TRY(stream.write_value(reason_phrase_size)); + TRY(stream.write_value(reason_phrase_hash)); + TRY(stream.write_value(headers_size)); + TRY(stream.write_value(headers_hash)); + return {}; +} + +ErrorOr CacheFooter::write_to_stream(Stream& stream) const +{ + TRY(stream.write_value(data_size)); + TRY(stream.write_value(crc32)); + return {}; +} + +ErrorOr CacheFooter::read_from_stream(Stream& stream) +{ + CacheFooter footer; + footer.data_size = TRY(stream.read_value()); + footer.crc32 = TRY(stream.read_value()); + return footer; +} + +CacheEntry::CacheEntry(DiskCache& disk_cache, CacheIndex& index, u64 cache_key, String url, LexicalPath path, CacheHeader cache_header) + : m_disk_cache(disk_cache) + , m_index(index) + , m_cache_key(cache_key) + , m_url(move(url)) + , m_path(move(path)) + , m_cache_header(cache_header) +{ +} + +void CacheEntry::remove() +{ + (void)FileSystem::remove(m_path.string(), FileSystem::RecursionMode::Disallowed); + m_index.remove_entry(m_cache_key); +} + +void CacheEntry::close_and_destory_cache_entry() +{ + m_disk_cache.cache_entry_closed({}, *this); +} + +ErrorOr> CacheEntryWriter::create(DiskCache& disk_cache, CacheIndex& index, u64 cache_key, String url, u32 status_code, Optional reason_phrase, HTTP::HeaderMap const& headers, UnixDateTime request_time) +{ + auto path = path_for_cache_key(disk_cache.cache_directory(), cache_key); + + auto unbuffered_file = TRY(Core::File::open(path.string(), Core::File::OpenMode::Write)); + auto file = TRY(Core::OutputBufferedFile::create(move(unbuffered_file))); + + CacheHeader cache_header; + + auto result = [&]() -> ErrorOr { + StringBuilder builder; + auto headers_serializer = TRY(JsonArraySerializer<>::try_create(builder)); + + for (auto const& header : headers.headers()) { + if (is_header_exempted_from_storage(header.name)) + continue; + + auto header_serializer = TRY(headers_serializer.add_object()); + TRY(header_serializer.add("name"sv, header.name)); + TRY(header_serializer.add("value"sv, header.value)); + TRY(header_serializer.finish()); + } + + TRY(headers_serializer.finish()); + + cache_header.url_size = url.byte_count(); + cache_header.url_hash = url.hash(); + + cache_header.status_code = status_code; + cache_header.reason_phrase_size = reason_phrase.has_value() ? reason_phrase->byte_count() : 0; + cache_header.reason_phrase_hash = reason_phrase.has_value() ? reason_phrase->hash() : 0; + + auto serialized_headers = builder.string_view(); + cache_header.headers_size = serialized_headers.length(); + cache_header.headers_hash = serialized_headers.hash(); + + TRY(file->write_value(cache_header)); + TRY(file->write_until_depleted(url)); + if (reason_phrase.has_value()) + TRY(file->write_until_depleted(*reason_phrase)); + TRY(file->write_until_depleted(serialized_headers)); + + return {}; + }(); + + if (result.is_error()) { + (void)FileSystem::remove(path.string(), FileSystem::RecursionMode::Disallowed); + return result.release_error(); + } + + return adopt_own(*new CacheEntryWriter { disk_cache, index, cache_key, move(url), path, move(file), cache_header, request_time }); +} + +CacheEntryWriter::CacheEntryWriter(DiskCache& disk_cache, CacheIndex& index, u64 cache_key, String url, LexicalPath path, NonnullOwnPtr file, CacheHeader cache_header, UnixDateTime request_time) + : CacheEntry(disk_cache, index, cache_key, move(url), move(path), cache_header) + , m_file(move(file)) + , m_request_time(request_time) + , m_response_time(UnixDateTime::now()) +{ +} + +ErrorOr CacheEntryWriter::write_data(ReadonlyBytes data) +{ + if (auto result = m_file->write_until_depleted(data); result.is_error()) { + dbgln("\033[31;1mUnable to write to cache entry for{}\033[0m {}: {}", m_url, result.error()); + + remove(); + close_and_destory_cache_entry(); + + return result.release_error(); + } + + m_cache_footer.data_size += data.size(); + + // FIXME: Update the crc. + + dbgln("\033[36;1mSaved {} bytes for\033[0m {}", data.size(), m_url); + return {}; +} + +ErrorOr CacheEntryWriter::flush() +{ + ScopeGuard guard { [&]() { close_and_destory_cache_entry(); } }; + + if (auto result = m_file->write_value(m_cache_footer); result.is_error()) { + dbgln("\033[31;1mUnable to flush cache entry for{}\033[0m {}: {}", m_url, result.error()); + remove(); + + return result.release_error(); + } + + m_index.create_entry(m_cache_key, m_url, m_cache_footer.data_size, m_request_time, m_response_time); + + dbgln("\033[34;1mFinished caching\033[0m {} ({} bytes)", m_url, m_cache_footer.data_size); + return {}; +} + +ErrorOr> CacheEntryReader::create(DiskCache& disk_cache, CacheIndex& index, u64 cache_key, u64 data_size) +{ + auto path = path_for_cache_key(disk_cache.cache_directory(), cache_key); + + auto file = TRY(Core::File::open(path.string(), Core::File::OpenMode::Read)); + auto fd = file->fd(); + + CacheHeader cache_header; + + String url; + Optional reason_phrase; + HTTP::HeaderMap headers; + + auto result = [&]() -> ErrorOr { + cache_header = TRY(file->read_value()); + + if (cache_header.magic != CacheHeader::CACHE_MAGIC) + return Error::from_string_literal("Magic value mismatch"); + if (cache_header.version != CacheHeader::CACHE_VERSION) + return Error::from_string_literal("Version mismatch"); + + url = TRY(String::from_stream(*file, cache_header.url_size)); + if (url.hash() != cache_header.url_hash) + return Error::from_string_literal("URL hash mismatch"); + + if (cache_header.reason_phrase_size != 0) { + reason_phrase = TRY(String::from_stream(*file, cache_header.reason_phrase_size)); + if (reason_phrase->hash() != cache_header.reason_phrase_hash) + return Error::from_string_literal("Reason phrase hash mismatch"); + } + + auto serialized_headers = TRY(String::from_stream(*file, cache_header.headers_size)); + if (serialized_headers.hash() != cache_header.headers_hash) + return Error::from_string_literal("HTTP headers hash mismatch"); + + auto json_headers = TRY(JsonValue::from_string(serialized_headers)); + if (!json_headers.is_array()) + return Error::from_string_literal("Expected HTTP headers to be a JSON array"); + + TRY(json_headers.as_array().try_for_each([&](JsonValue const& header) -> ErrorOr { + if (!header.is_object()) + return Error::from_string_literal("Expected headers entry to be a JSON object"); + + auto name = header.as_object().get_string("name"sv); + auto value = header.as_object().get_string("value"sv); + + if (!name.has_value() || !value.has_value()) + return Error::from_string_literal("Missing/invalid data in headers entry"); + + headers.set(name->to_byte_string(), value->to_byte_string()); + return {}; + })); + + return {}; + }(); + + if (result.is_error()) { + (void)FileSystem::remove(path.string(), FileSystem::RecursionMode::Disallowed); + return result.release_error(); + } + + auto data_offset = sizeof(CacheHeader) + cache_header.url_size + cache_header.reason_phrase_size + cache_header.headers_size; + + return adopt_own(*new CacheEntryReader { disk_cache, index, cache_key, move(url), move(path), move(file), fd, cache_header, move(reason_phrase), move(headers), data_offset, data_size }); +} + +CacheEntryReader::CacheEntryReader(DiskCache& disk_cache, CacheIndex& index, u64 cache_key, String url, LexicalPath path, NonnullOwnPtr file, int fd, CacheHeader cache_header, Optional reason_phrase, HTTP::HeaderMap header_map, u64 data_offset, u64 data_size) + : CacheEntry(disk_cache, index, cache_key, move(url), move(path), cache_header) + , m_file(move(file)) + , m_fd(fd) + , m_reason_phrase(move(reason_phrase)) + , m_headers(move(header_map)) + , m_data_offset(data_offset) + , m_data_size(data_size) +{ +} + +void CacheEntryReader::pipe_to(int pipe_fd, Function on_complete, Function on_error) +{ + VERIFY(m_pipe_fd == -1); + m_pipe_fd = pipe_fd; + + m_on_pipe_complete = move(on_complete); + m_on_pipe_error = move(on_error); + + m_pipe_write_notifier = Core::Notifier::construct(m_pipe_fd, Core::NotificationType::Write); + m_pipe_write_notifier->set_enabled(false); + + m_pipe_write_notifier->on_activation = [this]() { + m_pipe_write_notifier->set_enabled(false); + pipe_without_blocking(); + }; + + pipe_without_blocking(); +} + +void CacheEntryReader::pipe_without_blocking() +{ + auto result = Core::System::transfer_file_through_pipe(m_fd, m_pipe_fd, m_data_offset + m_bytes_piped, m_data_size - m_bytes_piped); + + if (result.is_error()) { + if (result.error().code() != EAGAIN && result.error().code() != EWOULDBLOCK) { + dbgln("\033[31;1mError transferring cache to pipe for\033[0m {}: {}", m_url, result.error()); + + if (m_on_pipe_error) + m_on_pipe_error(m_bytes_piped); + + close_and_destory_cache_entry(); + } else { + m_pipe_write_notifier->set_enabled(true); + } + + return; + } + + m_bytes_piped += result.value(); + + if (m_bytes_piped == m_data_size) { + pipe_complete(); + return; + } + + pipe_without_blocking(); +} + +void CacheEntryReader::pipe_complete() +{ + if (auto result = read_and_validate_footer(); result.is_error()) { + dbgln("\033[31;1mError validating cache entry for\033[0m {}: {}", m_url, result.error()); + remove(); + + if (m_on_pipe_error) + m_on_pipe_error(m_bytes_piped); + } else { + m_index.update_last_access_time(m_cache_key); + + if (m_on_pipe_complete) + m_on_pipe_complete(m_bytes_piped); + } + + close_and_destory_cache_entry(); +} + +ErrorOr CacheEntryReader::read_and_validate_footer() +{ + TRY(m_file->seek(m_data_offset + m_data_size, SeekMode::SetPosition)); + m_cache_footer = TRY(m_file->read_value()); + + if (m_cache_footer.data_size != m_data_size) + return Error::from_string_literal("Invalid data size in footer"); + + // FIXME: Validate the crc. + + return {}; +} + +} diff --git a/Services/RequestServer/Cache/CacheEntry.h b/Services/RequestServer/Cache/CacheEntry.h new file mode 100644 index 00000000000..62a60c7f14c --- /dev/null +++ b/Services/RequestServer/Cache/CacheEntry.h @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2025, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace RequestServer { + +struct [[gnu::packed]] CacheHeader { + static ErrorOr read_from_stream(Stream&); + ErrorOr write_to_stream(Stream&) const; + + static constexpr auto CACHE_MAGIC = 0xcafef00du; + static constexpr auto CACHE_VERSION = 1; + + u32 magic { CACHE_MAGIC }; + u32 version { CACHE_VERSION }; + + u32 url_size { 0 }; + u32 url_hash { 0 }; + + u32 status_code { 0 }; + u32 reason_phrase_size { 0 }; + u32 reason_phrase_hash { 0 }; + + u32 headers_size { 0 }; + u32 headers_hash { 0 }; +}; + +struct [[gnu::packed]] CacheFooter { + static ErrorOr read_from_stream(Stream&); + ErrorOr write_to_stream(Stream&) const; + + u64 data_size { 0 }; + u32 crc32 { 0 }; +}; + +// A cache entry is an amalgamation of all information needed to reconstruct HTTP responses. It is created once we have +// received the response headers for a request. The body is streamed into the entry as it is received. The cache format +// on disk is: +// +// [CacheHeader][URL][ReasonPhrase][HttpHeaders][Data][CacheFooter] +class CacheEntry { +public: + virtual ~CacheEntry() = default; + + void remove(); + +protected: + CacheEntry(DiskCache&, CacheIndex&, u64 cache_key, String url, LexicalPath, CacheHeader); + + void close_and_destory_cache_entry(); + + DiskCache& m_disk_cache; + CacheIndex& m_index; + + u64 m_cache_key { 0 }; + + String m_url; + LexicalPath m_path; + + CacheHeader m_cache_header; + CacheFooter m_cache_footer; +}; + +class CacheEntryWriter : public CacheEntry { +public: + static ErrorOr> create(DiskCache&, CacheIndex&, u64 cache_key, String url, u32 status_code, Optional reason_phrase, HTTP::HeaderMap const&, UnixDateTime request_time); + virtual ~CacheEntryWriter() override = default; + + ErrorOr write_data(ReadonlyBytes); + ErrorOr flush(); + +private: + CacheEntryWriter(DiskCache&, CacheIndex&, u64 cache_key, String url, LexicalPath, NonnullOwnPtr, CacheHeader, UnixDateTime request_time); + + NonnullOwnPtr m_file; + + UnixDateTime m_request_time; + UnixDateTime m_response_time; +}; + +class CacheEntryReader : public CacheEntry { +public: + static ErrorOr> create(DiskCache&, CacheIndex&, u64 cache_key, u64 data_size); + virtual ~CacheEntryReader() override = default; + + void pipe_to(int pipe_fd, Function on_complete, Function on_error); + + u32 status_code() const { return m_cache_header.status_code; } + Optional const& reason_phrase() const { return m_reason_phrase; } + HTTP::HeaderMap const& headers() const { return m_headers; } + +private: + CacheEntryReader(DiskCache&, CacheIndex&, u64 cache_key, String url, LexicalPath, NonnullOwnPtr, int fd, CacheHeader, Optional reason_phrase, HTTP::HeaderMap, u64 data_offset, u64 data_size); + + void pipe_without_blocking(); + void pipe_complete(); + + ErrorOr read_and_validate_footer(); + + NonnullOwnPtr m_file; + int m_fd { -1 }; + + RefPtr m_pipe_write_notifier; + int m_pipe_fd { -1 }; + + Function m_on_pipe_complete; + Function m_on_pipe_error; + u64 m_bytes_piped { 0 }; + + Optional m_reason_phrase; + HTTP::HeaderMap m_headers; + + u64 const m_data_offset { 0 }; + u64 const m_data_size { 0 }; +}; + +} diff --git a/Services/RequestServer/Cache/CacheIndex.cpp b/Services/RequestServer/Cache/CacheIndex.cpp new file mode 100644 index 00000000000..f454076ffec --- /dev/null +++ b/Services/RequestServer/Cache/CacheIndex.cpp @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2025, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include + +namespace RequestServer { + +ErrorOr CacheIndex::create(Database::Database& database) +{ + auto create_table = TRY(database.prepare_statement(R"#( + CREATE TABLE IF NOT EXISTS CacheIndex ( + cache_key INTEGER, + url TEXT, + data_size INTEGER, + request_time INTEGER, + response_time INTEGER, + last_access_time INTEGER, + PRIMARY KEY(cache_key) + );)#"sv)); + database.execute_statement(create_table, {}); + + Statements statements {}; + statements.insert_entry = TRY(database.prepare_statement("INSERT OR REPLACE INTO CacheIndex VALUES (?, ?, ?, ?, ?, ?);"sv)); + statements.remove_entry = TRY(database.prepare_statement("DELETE FROM CacheIndex WHERE cache_key = ?;"sv)); + statements.select_entry = TRY(database.prepare_statement("SELECT * FROM CacheIndex WHERE cache_key = ?;"sv)); + statements.update_last_access_time = TRY(database.prepare_statement("UPDATE CacheIndex SET last_access_time = ? WHERE cache_key = ?;"sv)); + + return CacheIndex { database, statements }; +} + +CacheIndex::CacheIndex(Database::Database& database, Statements statements) + : m_database(database) + , m_statements(statements) +{ +} + +void CacheIndex::create_entry(u64 cache_key, String url, u64 data_size, UnixDateTime request_time, UnixDateTime response_time) +{ + auto now = UnixDateTime::now(); + + Entry entry { + .cache_key = cache_key, + .url = move(url), + .data_size = data_size, + .request_time = request_time, + .response_time = response_time, + .last_access_time = now, + }; + + m_database.execute_statement(m_statements.insert_entry, {}, entry.cache_key, entry.url, entry.data_size, entry.request_time, entry.response_time, entry.last_access_time); + m_entries.set(cache_key, move(entry)); +} + +void CacheIndex::remove_entry(u64 cache_key) +{ + m_database.execute_statement(m_statements.remove_entry, {}, cache_key); + m_entries.remove(cache_key); +} + +void CacheIndex::update_last_access_time(u64 cache_key) +{ + auto entry = m_entries.get(cache_key); + if (!entry.has_value()) + return; + + auto now = UnixDateTime::now(); + + m_database.execute_statement(m_statements.update_last_access_time, {}, now, cache_key); + entry->last_access_time = now; +} + +Optional CacheIndex::find_entry(u64 cache_key) +{ + if (auto entry = m_entries.get(cache_key); entry.has_value()) + return entry; + + m_database.execute_statement( + m_statements.select_entry, [&](auto statement_id) { + int column = 0; + + auto cache_key = m_database.result_column(statement_id, column++); + auto url = m_database.result_column(statement_id, column++); + auto data_size = m_database.result_column(statement_id, column++); + auto request_time = m_database.result_column(statement_id, column++); + auto response_time = m_database.result_column(statement_id, column++); + auto last_access_time = m_database.result_column(statement_id, column++); + + Entry entry { cache_key, move(url), data_size, request_time, response_time, last_access_time }; + m_entries.set(cache_key, move(entry)); + }, + cache_key); + + return m_entries.get(cache_key); +} + +} diff --git a/Services/RequestServer/Cache/CacheIndex.h b/Services/RequestServer/Cache/CacheIndex.h new file mode 100644 index 00000000000..a1b2e4174d7 --- /dev/null +++ b/Services/RequestServer/Cache/CacheIndex.h @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include +#include +#include +#include + +namespace RequestServer { + +// The cache index is a SQL database containing metadata about each cache entry. An entry in the index is created once +// the entire cache entry has been successfully written to disk. +class CacheIndex { + struct Entry { + u64 cache_key { 0 }; + + String url; + u64 data_size { 0 }; + + UnixDateTime request_time; + UnixDateTime response_time; + UnixDateTime last_access_time; + }; + +public: + static ErrorOr create(Database::Database&); + + void create_entry(u64 cache_key, String url, u64 data_size, UnixDateTime request_time, UnixDateTime response_time); + void remove_entry(u64 cache_key); + + Optional find_entry(u64 cache_key); + + void update_last_access_time(u64 cache_key); + +private: + struct Statements { + Database::StatementID insert_entry { 0 }; + Database::StatementID remove_entry { 0 }; + Database::StatementID select_entry { 0 }; + Database::StatementID update_last_access_time { 0 }; + }; + + CacheIndex(Database::Database&, Statements); + + Database::Database& m_database; + Statements m_statements; + + HashMap m_entries; +}; + +} diff --git a/Services/RequestServer/Cache/DiskCache.cpp b/Services/RequestServer/Cache/DiskCache.cpp new file mode 100644 index 00000000000..9d4bf4953a1 --- /dev/null +++ b/Services/RequestServer/Cache/DiskCache.cpp @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2025, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include +#include + +namespace RequestServer { + +static constexpr auto INDEX_DATABASE = "INDEX"sv; + +ErrorOr DiskCache::create() +{ + auto cache_directory = LexicalPath::join(Core::StandardPaths::cache_directory(), "Ladybird"sv, "Cache"sv); + + auto database = TRY(Database::Database::create(cache_directory.string(), INDEX_DATABASE)); + auto index = TRY(CacheIndex::create(database)); + + return DiskCache { move(database), move(cache_directory), move(index) }; +} + +DiskCache::DiskCache(NonnullRefPtr database, LexicalPath cache_directory, CacheIndex index) + : m_database(move(database)) + , m_cache_directory(move(cache_directory)) + , m_index(move(index)) +{ +} + +Optional DiskCache::create_entry(URL::URL const& url, StringView method, u32 status_code, Optional reason_phrase, HTTP::HeaderMap const& headers, UnixDateTime request_time) +{ + if (!is_cacheable(method, status_code, headers)) + return {}; + + if (auto freshness = calculate_freshness_lifetime(headers); freshness.is_negative() || freshness.is_zero()) + return {}; + + auto serialized_url = serialize_url_for_cache_storage(url); + auto cache_key = create_cache_key(serialized_url, method); + + auto cache_entry = CacheEntryWriter::create(*this, m_index, cache_key, move(serialized_url), status_code, move(reason_phrase), headers, request_time); + if (cache_entry.is_error()) { + dbgln("\033[31;1mUnable to create cache entry for\033[0m {}: {}", url, cache_entry.error()); + return {}; + } + + dbgln("\033[32;1mCreated disk cache entry for\033[0m {}", url); + + auto address = reinterpret_cast(cache_entry.value().ptr()); + m_open_cache_entries.set(address, cache_entry.release_value()); + + return static_cast(**m_open_cache_entries.get(address)); +} + +Optional DiskCache::open_entry(URL::URL const& url, StringView method) +{ + auto serialized_url = serialize_url_for_cache_storage(url); + auto cache_key = create_cache_key(serialized_url, method); + + auto index_entry = m_index.find_entry(cache_key); + if (!index_entry.has_value()) { + dbgln("\033[35;1mNo disk cache entry for\033[0m {}", url); + return {}; + } + + auto cache_entry = CacheEntryReader::create(*this, m_index, cache_key, index_entry->data_size); + if (cache_entry.is_error()) { + dbgln("\033[31;1mUnable to open cache entry for\033[0m {}: {}", url, cache_entry.error()); + m_index.remove_entry(cache_key); + return {}; + } + + auto freshness_lifetime = calculate_freshness_lifetime(cache_entry.value()->headers()); + auto current_age = calculate_age(cache_entry.value()->headers(), index_entry->request_time, index_entry->response_time); + + if (!is_response_fresh(freshness_lifetime, current_age)) { + dbgln("\033[33;1mCache entry expired for\033[0m {} (lifetime={}s age={}s)", url, freshness_lifetime.to_seconds(), current_age.to_seconds()); + cache_entry.value()->remove(); + return {}; + } + + dbgln("\033[32;1mOpened disk cache entry for\033[0m {} (lifetime={}s age={}s) ({} bytes)", url, freshness_lifetime.to_seconds(), current_age.to_seconds(), index_entry->data_size); + + auto address = reinterpret_cast(cache_entry.value().ptr()); + m_open_cache_entries.set(address, cache_entry.release_value()); + + return static_cast(**m_open_cache_entries.get(address)); +} + +void DiskCache::cache_entry_closed(Badge, CacheEntry const& cache_entry) +{ + auto address = reinterpret_cast(&cache_entry); + m_open_cache_entries.remove(address); +} + +} diff --git a/Services/RequestServer/Cache/DiskCache.h b/Services/RequestServer/Cache/DiskCache.h new file mode 100644 index 00000000000..09cdc42e48d --- /dev/null +++ b/Services/RequestServer/Cache/DiskCache.h @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace RequestServer { + +class DiskCache { +public: + static ErrorOr create(); + + Optional create_entry(URL::URL const&, StringView method, u32 status_code, Optional reason_phrase, HTTP::HeaderMap const&, UnixDateTime request_time); + Optional open_entry(URL::URL const&, StringView method); + + LexicalPath const& cache_directory() { return m_cache_directory; } + + void cache_entry_closed(Badge, CacheEntry const&); + +private: + DiskCache(NonnullRefPtr, LexicalPath cache_directory, CacheIndex); + + NonnullRefPtr m_database; + + HashMap> m_open_cache_entries; + + LexicalPath m_cache_directory; + CacheIndex m_index; +}; + +} diff --git a/Services/RequestServer/Cache/Utilities.cpp b/Services/RequestServer/Cache/Utilities.cpp new file mode 100644 index 00000000000..6d240d097a2 --- /dev/null +++ b/Services/RequestServer/Cache/Utilities.cpp @@ -0,0 +1,220 @@ +/* + * Copyright (c) 2025, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include + +namespace RequestServer { + +static Optional extract_cache_control_directive(StringView cache_control, StringView directive) +{ + Optional result; + + cache_control.for_each_split_view(","sv, SplitBehavior::Nothing, [&](StringView candidate) { + if (!candidate.contains(directive, CaseSensitivity::CaseInsensitive)) + return IterationDecision::Continue; + + auto index = candidate.find('='); + if (!index.has_value()) + return IterationDecision::Continue; + + result = candidate.substring_view(*index + 1); + return IterationDecision::Break; + }); + + return result; +} + +// https://httpwg.org/specs/rfc9110.html#field.date +static Optional parse_http_date(Optional date) +{ + // , :: GMT + if (date.has_value()) + return UnixDateTime::parse("%a, %d %b %Y %T GMT"sv, *date, true); + return {}; +} + +String serialize_url_for_cache_storage(URL::URL const& url) +{ + if (!url.fragment().has_value()) + return url.serialize(); + + auto sanitized = url; + sanitized.set_fragment({}); + return sanitized.serialize(); +} + +u64 create_cache_key(StringView url, StringView method) +{ + auto hasher = Crypto::Hash::SHA1::create(); + hasher->update(url); + hasher->update(method); + + auto digest = hasher->digest(); + auto bytes = digest.bytes(); + + u64 result = 0; + result |= static_cast(bytes[0]) << 56; + result |= static_cast(bytes[1]) << 48; + result |= static_cast(bytes[2]) << 40; + result |= static_cast(bytes[3]) << 32; + result |= static_cast(bytes[4]) << 24; + result |= static_cast(bytes[5]) << 16; + result |= static_cast(bytes[6]) << 8; + result |= static_cast(bytes[7]); + + return result; +} + +// https://httpwg.org/specs/rfc9111.html#response.cacheability +bool is_cacheable(StringView method, u32 status_code, HTTP::HeaderMap const& headers) +{ + // A cache MUST NOT store a response to a request unless: + + // * the request method is understood by the cache; + if (!method.is_one_of("GET"sv, "HEAD"sv)) + return false; + + // * the response status code is final (see Section 15 of [HTTP]); + if (status_code < 200) + return false; + + auto cache_control = headers.get("Cache-Control"sv); + if (!cache_control.has_value()) + return false; + + // * if the response status code is 206 or 304, or the must-understand cache directive (see Section 5.2.2.3) is + // present: the cache understands the response status code; + + // * the no-store cache directive is not present in the response (see Section 5.2.2.5); + if (cache_control->contains("no-store"sv, CaseSensitivity::CaseInsensitive)) + return false; + + // * if the cache is shared: the private response directive is either not present or allows a shared cache to store + // a modified response; see Section 5.2.2.7); + + // * if the cache is shared: the Authorization header field is not present in the request (see Section 11.6.2 of + // [HTTP]) or a response directive is present that explicitly allows shared caching (see Section 3.5); and + + // * the response contains at least one of the following: + // - a public response directive (see Section 5.2.2.9); + // - a private response directive, if the cache is not shared (see Section 5.2.2.7); + // - an Expires header field (see Section 5.3); + // - a max-age response directive (see Section 5.2.2.1); + // - if the cache is shared: an s-maxage response directive (see Section 5.2.2.10); + // - a cache extension that allows it to be cached (see Section 5.2.3); or + // - a status code that is defined as heuristically cacheable (see Section 4.2.2). + + // FIXME: Implement cache revalidation. + if (cache_control->contains("no-cache"sv, CaseSensitivity::CaseInsensitive)) + return false; + if (cache_control->contains("revalidate"sv, CaseSensitivity::CaseInsensitive)) + return false; + + return true; +} + +// https://httpwg.org/specs/rfc9111.html#storing.fields +bool is_header_exempted_from_storage(StringView name) +{ + // Caches MUST include all received response header fields — including unrecognized ones — when storing a response; + // this assures that new HTTP header fields can be successfully deployed. However, the following exceptions are made: + return name.is_one_of_ignoring_ascii_case( + // * The Connection header field and fields whose names are listed in it are required by Section 7.6.1 of [HTTP] + // to be removed before forwarding the message. This MAY be implemented by doing so before storage. + "Connection"sv, + "Keep-Alive"sv, + "Proxy-Connection"sv, + "TE"sv, + "Transfer-Encoding"sv, + "Upgrade"sv + + // * Likewise, some fields' semantics require them to be removed before forwarding the message, and this MAY be + // implemented by doing so before storage; see Section 7.6.1 of [HTTP] for some examples. + + // * The no-cache (Section 5.2.2.4) and private (Section 5.2.2.7) cache directives can have arguments that + // prevent storage of header fields by all caches and shared caches, respectively. + + // * Header fields that are specific to the proxy that a cache uses when forwarding a request MUST NOT be stored, + // unless the cache incorporates the identity of the proxy into the cache key. Effectively, this is limited to + // Proxy-Authenticate (Section 11.7.1 of [HTTP]), Proxy-Authentication-Info (Section 11.7.3 of [HTTP]), and + // Proxy-Authorization (Section 11.7.2 of [HTTP]). + ); +} + +// https://httpwg.org/specs/rfc9111.html#calculating.freshness.lifetime +AK::Duration calculate_freshness_lifetime(HTTP::HeaderMap const& headers) +{ + // A cache can calculate the freshness lifetime (denoted as freshness_lifetime) of a response by evaluating the + // following rules and using the first match: + + // * If the cache is shared and the s-maxage response directive (Section 5.2.2.10) is present, use its value, or + + // * If the max-age response directive (Section 5.2.2.1) is present, use its value, or + if (auto cache_control = headers.get("Cache-Control"sv); cache_control.has_value()) { + if (auto max_age = extract_cache_control_directive(*cache_control, "max-age"sv); max_age.has_value()) { + if (auto seconds = max_age->to_number(); seconds.has_value()) + return AK::Duration::from_seconds(*seconds); + } + } + + // * If the Expires response header field (Section 5.3) is present, use its value minus the value of the Date response + // header field (using the time the message was received if it is not present, as per Section 6.6.1 of [HTTP]), or + if (auto expires = parse_http_date(headers.get("Expires"sv)); expires.has_value()) { + auto date = parse_http_date(headers.get("Date"sv)).value_or_lazy_evaluated([]() { + return UnixDateTime::now(); + }); + + return *expires - date; + } + + // * Otherwise, no explicit expiration time is present in the response. A heuristic freshness lifetime might be + // applicable; see Section 4.2.2. + + return {}; +} + +// https://httpwg.org/specs/rfc9111.html#age.calculations +AK::Duration calculate_age(HTTP::HeaderMap const& headers, UnixDateTime request_time, UnixDateTime response_time) +{ + // The term "age_value" denotes the value of the Age header field (Section 5.1), in a form appropriate for arithmetic + // operation; or 0, if not available. + AK::Duration age_value; + + if (auto age = headers.get("Age"sv); age.has_value()) { + if (auto seconds = age->to_number(); seconds.has_value()) + age_value = AK::Duration::from_seconds(*seconds); + } + + // The term "now" means the current value of this implementation's clock (Section 5.6.7 of [HTTP]). + auto now = UnixDateTime::now(); + + // The term "date_value" denotes the value of the Date header field, in a form appropriate for arithmetic operations. + // See Section 6.6.1 of [HTTP] for the definition of the Date header field and for requirements regarding responses + // without it. + auto date_value = parse_http_date(headers.get("Date"sv)).value_or(now); + + auto apparent_age = max(0LL, (response_time - date_value).to_seconds()); + + auto response_delay = response_time - request_time; + auto corrected_age_value = age_value + response_delay; + + auto corrected_initial_age = max(apparent_age, corrected_age_value.to_seconds()); + + auto resident_time = (now - response_time).to_seconds(); + auto current_age = corrected_initial_age + resident_time; + + return AK::Duration::from_seconds(current_age); +} + +// https://httpwg.org/specs/rfc9111.html#expiration.model +bool is_response_fresh(AK::Duration freshness_lifetime, AK::Duration current_age) +{ + return freshness_lifetime > current_age; +} + +} diff --git a/Services/RequestServer/Cache/Utilities.h b/Services/RequestServer/Cache/Utilities.h new file mode 100644 index 00000000000..78f4c6d21de --- /dev/null +++ b/Services/RequestServer/Cache/Utilities.h @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include +#include +#include +#include + +namespace RequestServer { + +String serialize_url_for_cache_storage(URL::URL const&); +u64 create_cache_key(StringView url, StringView method); + +bool is_cacheable(StringView method, u32 status_code, HTTP::HeaderMap const&); +bool is_header_exempted_from_storage(StringView name); + +AK::Duration calculate_freshness_lifetime(HTTP::HeaderMap const&); +AK::Duration calculate_age(HTTP::HeaderMap const&, UnixDateTime request_time, UnixDateTime response_time); +bool is_response_fresh(AK::Duration freshness_lifetime, AK::Duration current_age); + +} diff --git a/Services/RequestServer/ConnectionFromClient.cpp b/Services/RequestServer/ConnectionFromClient.cpp index e598e460d8b..6461ce88954 100644 --- a/Services/RequestServer/ConnectionFromClient.cpp +++ b/Services/RequestServer/ConnectionFromClient.cpp @@ -20,12 +20,15 @@ #include #include #include +#include #include #include + #ifdef AK_OS_WINDOWS // needed because curl.h includes winsock2.h # include #endif + #include namespace RequestServer { @@ -42,6 +45,8 @@ static struct { bool validate_dnssec_locally = false; } g_dns_info; +Optional g_disk_cache; + static WeakPtr s_resolver {}; static NonnullRefPtr default_resolver() { @@ -116,13 +121,17 @@ struct ConnectionFromClient::ActiveRequest : public Weakable { bool got_all_headers { false }; bool is_connect_only { false }; size_t downloaded_so_far { 0 }; - String url; + URL::URL url; + ByteString method; Optional reason_phrase; ByteBuffer body; AllocatingMemoryStream send_buffer; NonnullRefPtr write_notifier; bool done_fetching { false }; + Optional cache_entry; + UnixDateTime request_start_time; + ActiveRequest(ConnectionFromClient& client, CURLM* multi, CURL* easy, i32 request_id, int writer_fd) : multi(multi) , easy(easy) @@ -130,6 +139,7 @@ struct ConnectionFromClient::ActiveRequest : public Weakable { , client(client) , writer_fd(writer_fd) , write_notifier(Core::Notifier::construct(writer_fd, Core::NotificationType::Write)) + , request_start_time(UnixDateTime::now()) { write_notifier->set_enabled(false); write_notifier->on_activation = [this] { @@ -163,6 +173,13 @@ struct ConnectionFromClient::ActiveRequest : public Weakable { return {}; } + if (cache_entry.has_value()) { + auto bytes_sent = bytes_to_send.span().slice(0, result.value()); + + if (cache_entry->write_data(bytes_sent).is_error()) + cache_entry.clear(); + } + MUST(send_buffer.discard(result.value())); write_notifier->set_enabled(!send_buffer.is_eof()); if (send_buffer.is_eof() && done_fetching) @@ -193,6 +210,9 @@ struct ConnectionFromClient::ActiveRequest : public Weakable { for (auto* string_list : curl_string_lists) curl_slist_free_all(string_list); + + if (cache_entry.has_value()) + (void)cache_entry->flush(); } void flush_headers_if_needed() @@ -204,6 +224,9 @@ struct ConnectionFromClient::ActiveRequest : public Weakable { auto result = curl_easy_getinfo(easy, CURLINFO_RESPONSE_CODE, &http_status_code); VERIFY(result == CURLE_OK); client->async_headers_became_available(request_id, headers, http_status_code, reason_phrase); + + if (g_disk_cache.has_value()) + cache_entry = g_disk_cache->create_entry(url, method, http_status_code, reason_phrase, headers, request_start_time); } }; @@ -464,6 +487,33 @@ void ConnectionFromClient::start_request(i32, ByteString, URL::URL, HTTP::Header void ConnectionFromClient::start_request(i32 request_id, ByteString method, URL::URL url, HTTP::HeaderMap request_headers, ByteBuffer request_body, Core::ProxyData proxy_data) { dbgln_if(REQUESTSERVER_DEBUG, "RequestServer: start_request({}, {})", request_id, url); + + if (g_disk_cache.has_value()) { + if (auto cache_entry = g_disk_cache->open_entry(url, method); cache_entry.has_value()) { + auto fds = MUST(Core::System::pipe2(O_NONBLOCK)); + auto writer_fd = fds[1]; + auto reader_fd = fds[0]; + + async_request_started(request_id, IPC::File::adopt_fd(reader_fd)); + async_headers_became_available(request_id, cache_entry->headers(), cache_entry->status_code(), cache_entry->reason_phrase()); + + cache_entry->pipe_to( + writer_fd, + [this, request_id, writer_fd](auto bytes_sent) { + // FIXME: Implement timing info for cache hits. + async_request_finished(request_id, bytes_sent, {}, {}); + MUST(Core::System::close(writer_fd)); + }, + [this, request_id, writer_fd](auto bytes_sent) { + // FIXME: We should switch to a network request automatically if reading from cache has failed. + async_request_finished(request_id, bytes_sent, {}, Requests::NetworkError::CacheReadFailed); + (void)Core::System::close(writer_fd); + }); + + return; + } + } + auto host = url.serialized_host().to_byte_string(); m_resolver->dns.lookup(host, DNS::Messages::Class::IN, { DNS::Messages::ResourceType::A, DNS::Messages::ResourceType::AAAA }, { .validate_dnssec_locally = g_dns_info.validate_dnssec_locally }) @@ -500,7 +550,8 @@ void ConnectionFromClient::start_request(i32 request_id, ByteString method, URL: async_request_started(request_id, IPC::File::adopt_fd(reader_fd)); auto request = make(*this, m_curl_multi, easy, request_id, writer_fd); - request->url = url.to_string(); + request->url = url; + request->method = method; auto set_option = [easy](auto option, auto value) { auto result = curl_easy_setopt(easy, option, value); @@ -760,8 +811,6 @@ Messages::RequestServer::SetCertificateResponse ConnectionFromClient::set_certif void ConnectionFromClient::ensure_connection(URL::URL url, ::RequestServer::CacheLevel cache_level) { - auto const url_string_value = url.to_string(); - if (cache_level == CacheLevel::CreateConnection) { auto* easy = curl_easy_init(); if (!easy) { @@ -781,11 +830,11 @@ void ConnectionFromClient::ensure_connection(URL::URL url, ::RequestServer::Cach auto connect_only_request_id = get_random(); auto request = make(*this, m_curl_multi, easy, connect_only_request_id, 0); - request->url = url_string_value; + request->url = url; request->is_connect_only = true; set_option(CURLOPT_PRIVATE, request.ptr()); - set_option(CURLOPT_URL, url_string_value.to_byte_string().characters()); + set_option(CURLOPT_URL, url.to_byte_string().characters()); set_option(CURLOPT_PORT, url.port_or_default()); set_option(CURLOPT_CONNECTTIMEOUT, s_connect_timeout_seconds); set_option(CURLOPT_CONNECT_ONLY, 1L); diff --git a/Services/RequestServer/Forward.h b/Services/RequestServer/Forward.h new file mode 100644 index 00000000000..e78918ece1f --- /dev/null +++ b/Services/RequestServer/Forward.h @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +namespace RequestServer { + +class CacheEntry; +class CacheEntryReader; +class CacheEntryWriter; +class CacheIndex; +class DiskCache; + +} diff --git a/Services/RequestServer/main.cpp b/Services/RequestServer/main.cpp index ae2f16dffc9..67561184dad 100644 --- a/Services/RequestServer/main.cpp +++ b/Services/RequestServer/main.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #if defined(AK_OS_MACOS) @@ -23,6 +24,7 @@ namespace RequestServer { extern ByteString g_default_certificate_path; +extern Optional g_disk_cache; } @@ -32,11 +34,13 @@ ErrorOr ladybird_main(Main::Arguments arguments) Vector certificates; StringView mach_server_name; + bool enable_http_disk_cache = false; bool wait_for_debugger = false; Core::ArgsParser args_parser; args_parser.add_option(certificates, "Path to a certificate file", "certificate", 'C', "certificate"); args_parser.add_option(mach_server_name, "Mach server name", "mach-server-name", 0, "mach_server_name"); + args_parser.add_option(enable_http_disk_cache, "Enable HTTP disk cache", "enable-http-disk-cache"); args_parser.add_option(wait_for_debugger, "Wait for debugger", "wait-for-debugger"); args_parser.parse(arguments); @@ -54,6 +58,13 @@ ErrorOr ladybird_main(Main::Arguments arguments) Core::Platform::register_with_mach_server(mach_server_name); #endif + if (enable_http_disk_cache) { + if (auto cache = RequestServer::DiskCache::create(); cache.is_error()) + warnln("Unable to create disk cache: {}", cache.error()); + else + RequestServer::g_disk_cache = cache.release_value(); + } + auto client = TRY(IPC::take_over_accepted_client_from_system_server()); return event_loop.exec(); From 42eaea1043d2a40cf1ccd0dbb8e62dda114d043a Mon Sep 17 00:00:00 2001 From: Timothy Flynn Date: Wed, 8 Oct 2025 21:17:05 -0400 Subject: [PATCH 20/27] LibWebView: Add a command line flag to enable the HTTP disk cache This adds a RequestServerOptions structure to hold this option and the only other RS option we currently have (certificates). --- Libraries/LibWebView/Application.cpp | 8 +++++++- Libraries/LibWebView/Application.h | 2 ++ Libraries/LibWebView/HelperProcess.cpp | 8 +++++++- Libraries/LibWebView/Options.h | 11 ++++++++++- 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/Libraries/LibWebView/Application.cpp b/Libraries/LibWebView/Application.cpp index 6248832e515..040e97bc374 100644 --- a/Libraries/LibWebView/Application.cpp +++ b/Libraries/LibWebView/Application.cpp @@ -117,6 +117,7 @@ ErrorOr Application::initialize(Main::Arguments const& arguments) bool disable_site_isolation = false; bool enable_idl_tracing = false; bool disable_http_cache = false; + bool enable_http_disk_cache = false; bool enable_autoplay = false; bool expose_internals_object = false; bool force_cpu_painting = false; @@ -164,6 +165,7 @@ ErrorOr Application::initialize(Main::Arguments const& arguments) args_parser.add_option(disable_site_isolation, "Disable site isolation", "disable-site-isolation"); args_parser.add_option(enable_idl_tracing, "Enable IDL tracing", "enable-idl-tracing"); args_parser.add_option(disable_http_cache, "Disable HTTP cache", "disable-http-cache"); + args_parser.add_option(enable_http_disk_cache, "Enable HTTP disk cache", "enable-http-disk-cache"); args_parser.add_option(enable_autoplay, "Enable multimedia autoplay", "enable-autoplay"); args_parser.add_option(expose_internals_object, "Expose internals object", "expose-internals-object"); args_parser.add_option(force_cpu_painting, "Force CPU painting", "force-cpu-painting"); @@ -228,7 +230,6 @@ ErrorOr Application::initialize(Main::Arguments const& arguments) .urls = sanitize_urls(raw_urls, m_settings.new_tab_page_url()), .raw_urls = move(raw_urls), .headless_mode = headless_mode, - .certificates = move(certificates), .new_window = new_window ? NewWindow::Yes : NewWindow::No, .force_new_process = force_new_process ? ForceNewProcess::Yes : ForceNewProcess::No, .allow_popups = allow_popups ? AllowPopups::Yes : AllowPopups::No, @@ -252,6 +253,11 @@ ErrorOr Application::initialize(Main::Arguments const& arguments) if (webdriver_content_ipc_path.has_value()) m_browser_options.webdriver_content_ipc_path = *webdriver_content_ipc_path; + m_request_server_options = { + .certificates = move(certificates), + .enable_http_disk_cache = enable_http_disk_cache ? EnableHTTPDiskCache::Yes : EnableHTTPDiskCache::No, + }; + m_web_content_options = { .command_line = MUST(String::join(' ', m_arguments.strings)), .executable_path = MUST(String::from_byte_string(MUST(Core::System::current_executable_path()))), diff --git a/Libraries/LibWebView/Application.h b/Libraries/LibWebView/Application.h index 75c53c9fae7..424615c5260 100644 --- a/Libraries/LibWebView/Application.h +++ b/Libraries/LibWebView/Application.h @@ -48,6 +48,7 @@ public: static Settings& settings() { return the().m_settings; } static BrowserOptions const& browser_options() { return the().m_browser_options; } + static RequestServerOptions const& request_server_options() { return the().m_request_server_options; } static WebContentOptions& web_content_options() { return the().m_web_content_options; } static Requests::RequestClient& request_server_client() { return *the().m_request_server_client; } @@ -174,6 +175,7 @@ private: Main::Arguments m_arguments; BrowserOptions m_browser_options; + RequestServerOptions m_request_server_options; WebContentOptions m_web_content_options; RefPtr m_request_server_client; diff --git a/Libraries/LibWebView/HelperProcess.cpp b/Libraries/LibWebView/HelperProcess.cpp index af7c29d15d1..b97d0527f96 100644 --- a/Libraries/LibWebView/HelperProcess.cpp +++ b/Libraries/LibWebView/HelperProcess.cpp @@ -201,17 +201,23 @@ ErrorOr> launch_web_worker_process(Web ErrorOr> launch_request_server_process() { + auto const& request_server_options = Application::request_server_options(); + Vector arguments; - for (auto const& certificate : WebView::Application::browser_options().certificates) + for (auto const& certificate : request_server_options.certificates) arguments.append(ByteString::formatted("--certificate={}", certificate)); + if (request_server_options.enable_http_disk_cache == EnableHTTPDiskCache::Yes) + arguments.append("--enable-http-disk-cache"sv); + if (auto server = mach_server_name(); server.has_value()) { arguments.append("--mach-server-name"sv); arguments.append(server.value()); } auto client = TRY(launch_server_process("RequestServer"sv, move(arguments))); + WebView::Application::settings().dns_settings().visit( [](WebView::SystemDNS) {}, [&](WebView::DNSOverTLS const& dns_over_tls) { diff --git a/Libraries/LibWebView/Options.h b/Libraries/LibWebView/Options.h index 6380faa84e5..b4decc00bef 100644 --- a/Libraries/LibWebView/Options.h +++ b/Libraries/LibWebView/Options.h @@ -74,7 +74,6 @@ struct BrowserOptions { Optional headless_mode; int window_width { 800 }; int window_height { 600 }; - Vector certificates {}; NewWindow new_window { NewWindow::No }; ForceNewProcess force_new_process { ForceNewProcess::No }; AllowPopups allow_popups { AllowPopups::No }; @@ -87,6 +86,16 @@ struct BrowserOptions { Optional devtools_port; }; +enum class EnableHTTPDiskCache { + No, + Yes, +}; + +struct RequestServerOptions { + Vector certificates; + EnableHTTPDiskCache enable_http_disk_cache { EnableHTTPDiskCache::No }; +}; + enum class IsLayoutTestMode { No, Yes, From 163e8e5b44029a4d38b3b7bb4c7bf80afd4a27cf Mon Sep 17 00:00:00 2001 From: Timothy Flynn Date: Thu, 9 Oct 2025 14:24:47 -0400 Subject: [PATCH 21/27] LibWebView+RequestServer: Support clearing the HTTP disk cache This is a bit of a blunt hammer, but this hooks an action to clear the HTTP disk cache into the existing Clear Cache action. Upon invocation, it stops all existing cache entries from making further progress, and then deletes the entire cache index and all cache files. In the future, we will of course want more fine-grained control over cache deletion, e.g. via an about:history page. --- Libraries/LibWebView/Application.cpp | 5 ++- Services/RequestServer/Cache/CacheEntry.cpp | 40 ++++++++++++++----- Services/RequestServer/Cache/CacheEntry.h | 5 +++ Services/RequestServer/Cache/CacheIndex.cpp | 7 ++++ Services/RequestServer/Cache/CacheIndex.h | 2 + Services/RequestServer/Cache/DiskCache.cpp | 24 +++++++++++ Services/RequestServer/Cache/DiskCache.h | 1 + .../RequestServer/ConnectionFromClient.cpp | 6 +++ Services/RequestServer/ConnectionFromClient.h | 2 + Services/RequestServer/RequestServer.ipc | 2 + 10 files changed, 84 insertions(+), 10 deletions(-) diff --git a/Libraries/LibWebView/Application.cpp b/Libraries/LibWebView/Application.cpp index 040e97bc374..525efcb9247 100644 --- a/Libraries/LibWebView/Application.cpp +++ b/Libraries/LibWebView/Application.cpp @@ -824,7 +824,10 @@ void Application::initialize_actions() m_debug_menu->add_separator(); m_debug_menu->add_action(Action::create("Collect Garbage"sv, ActionID::CollectGarbage, debug_request("collect-garbage"sv))); - m_debug_menu->add_action(Action::create("Clear Cache"sv, ActionID::ClearCache, debug_request("clear-cache"sv))); + m_debug_menu->add_action(Action::create("Clear Cache"sv, ActionID::ClearCache, [this, clear_memory_cache = debug_request("clear_cache")]() { + m_request_server_client->async_clear_cache(); + clear_memory_cache(); + })); m_debug_menu->add_action(Action::create("Clear All Cookies"sv, ActionID::ClearCookies, [this]() { m_cookie_jar->clear_all_cookies(); })); m_debug_menu->add_separator(); diff --git a/Services/RequestServer/Cache/CacheEntry.cpp b/Services/RequestServer/Cache/CacheEntry.cpp index 98ce603fe1d..b975af67e94 100644 --- a/Services/RequestServer/Cache/CacheEntry.cpp +++ b/Services/RequestServer/Cache/CacheEntry.cpp @@ -153,6 +153,11 @@ CacheEntryWriter::CacheEntryWriter(DiskCache& disk_cache, CacheIndex& index, u64 ErrorOr CacheEntryWriter::write_data(ReadonlyBytes data) { + if (m_marked_for_deletion) { + close_and_destory_cache_entry(); + return Error::from_string_literal("Cache entry has been deleted"); + } + if (auto result = m_file->write_until_depleted(data); result.is_error()) { dbgln("\033[31;1mUnable to write to cache entry for{}\033[0m {}: {}", m_url, result.error()); @@ -174,6 +179,9 @@ ErrorOr CacheEntryWriter::flush() { ScopeGuard guard { [&]() { close_and_destory_cache_entry(); } }; + if (m_marked_for_deletion) + return Error::from_string_literal("Cache entry has been deleted"); + if (auto result = m_file->write_value(m_cache_footer); result.is_error()) { dbgln("\033[31;1mUnable to flush cache entry for{}\033[0m {}: {}", m_url, result.error()); remove(); @@ -272,6 +280,11 @@ void CacheEntryReader::pipe_to(int pipe_fd, Function on_complete, Fun m_on_pipe_complete = move(on_complete); m_on_pipe_error = move(on_error); + if (m_marked_for_deletion) { + pipe_error(Error::from_string_literal("Cache entry has been deleted")); + return; + } + m_pipe_write_notifier = Core::Notifier::construct(m_pipe_fd, Core::NotificationType::Write); m_pipe_write_notifier->set_enabled(false); @@ -285,19 +298,18 @@ void CacheEntryReader::pipe_to(int pipe_fd, Function on_complete, Fun void CacheEntryReader::pipe_without_blocking() { + if (m_marked_for_deletion) { + pipe_error(Error::from_string_literal("Cache entry has been deleted")); + return; + } + auto result = Core::System::transfer_file_through_pipe(m_fd, m_pipe_fd, m_data_offset + m_bytes_piped, m_data_size - m_bytes_piped); if (result.is_error()) { - if (result.error().code() != EAGAIN && result.error().code() != EWOULDBLOCK) { - dbgln("\033[31;1mError transferring cache to pipe for\033[0m {}: {}", m_url, result.error()); - - if (m_on_pipe_error) - m_on_pipe_error(m_bytes_piped); - - close_and_destory_cache_entry(); - } else { + if (result.error().code() != EAGAIN && result.error().code() != EWOULDBLOCK) + pipe_error(result.release_error()); + else m_pipe_write_notifier->set_enabled(true); - } return; } @@ -330,6 +342,16 @@ void CacheEntryReader::pipe_complete() close_and_destory_cache_entry(); } +void CacheEntryReader::pipe_error(Error error) +{ + dbgln("\033[31;1mError transferring cache to pipe for\033[0m {}: {}", m_url, error); + + if (m_on_pipe_error) + m_on_pipe_error(m_bytes_piped); + + close_and_destory_cache_entry(); +} + ErrorOr CacheEntryReader::read_and_validate_footer() { TRY(m_file->seek(m_data_offset + m_data_size, SeekMode::SetPosition)); diff --git a/Services/RequestServer/Cache/CacheEntry.h b/Services/RequestServer/Cache/CacheEntry.h index 62a60c7f14c..1c8ab8f8323 100644 --- a/Services/RequestServer/Cache/CacheEntry.h +++ b/Services/RequestServer/Cache/CacheEntry.h @@ -57,6 +57,8 @@ public: void remove(); + void mark_for_deletion(Badge) { m_marked_for_deletion = true; } + protected: CacheEntry(DiskCache&, CacheIndex&, u64 cache_key, String url, LexicalPath, CacheHeader); @@ -72,6 +74,8 @@ protected: CacheHeader m_cache_header; CacheFooter m_cache_footer; + + bool m_marked_for_deletion { false }; }; class CacheEntryWriter : public CacheEntry { @@ -107,6 +111,7 @@ private: void pipe_without_blocking(); void pipe_complete(); + void pipe_error(Error); ErrorOr read_and_validate_footer(); diff --git a/Services/RequestServer/Cache/CacheIndex.cpp b/Services/RequestServer/Cache/CacheIndex.cpp index f454076ffec..9b6da3e020e 100644 --- a/Services/RequestServer/Cache/CacheIndex.cpp +++ b/Services/RequestServer/Cache/CacheIndex.cpp @@ -25,6 +25,7 @@ ErrorOr CacheIndex::create(Database::Database& database) Statements statements {}; statements.insert_entry = TRY(database.prepare_statement("INSERT OR REPLACE INTO CacheIndex VALUES (?, ?, ?, ?, ?, ?);"sv)); statements.remove_entry = TRY(database.prepare_statement("DELETE FROM CacheIndex WHERE cache_key = ?;"sv)); + statements.remove_all_entries = TRY(database.prepare_statement("DELETE FROM CacheIndex;"sv)); statements.select_entry = TRY(database.prepare_statement("SELECT * FROM CacheIndex WHERE cache_key = ?;"sv)); statements.update_last_access_time = TRY(database.prepare_statement("UPDATE CacheIndex SET last_access_time = ? WHERE cache_key = ?;"sv)); @@ -60,6 +61,12 @@ void CacheIndex::remove_entry(u64 cache_key) m_entries.remove(cache_key); } +void CacheIndex::remove_all_entries() +{ + m_database.execute_statement(m_statements.remove_all_entries, {}); + m_entries.clear(); +} + void CacheIndex::update_last_access_time(u64 cache_key) { auto entry = m_entries.get(cache_key); diff --git a/Services/RequestServer/Cache/CacheIndex.h b/Services/RequestServer/Cache/CacheIndex.h index a1b2e4174d7..47fe55a0853 100644 --- a/Services/RequestServer/Cache/CacheIndex.h +++ b/Services/RequestServer/Cache/CacheIndex.h @@ -33,6 +33,7 @@ public: void create_entry(u64 cache_key, String url, u64 data_size, UnixDateTime request_time, UnixDateTime response_time); void remove_entry(u64 cache_key); + void remove_all_entries(); Optional find_entry(u64 cache_key); @@ -42,6 +43,7 @@ private: struct Statements { Database::StatementID insert_entry { 0 }; Database::StatementID remove_entry { 0 }; + Database::StatementID remove_all_entries { 0 }; Database::StatementID select_entry { 0 }; Database::StatementID update_last_access_time { 0 }; }; diff --git a/Services/RequestServer/Cache/DiskCache.cpp b/Services/RequestServer/Cache/DiskCache.cpp index 9d4bf4953a1..f0cf3a8c093 100644 --- a/Services/RequestServer/Cache/DiskCache.cpp +++ b/Services/RequestServer/Cache/DiskCache.cpp @@ -4,7 +4,9 @@ * SPDX-License-Identifier: BSD-2-Clause */ +#include #include +#include #include #include #include @@ -90,6 +92,28 @@ Optional DiskCache::open_entry(URL::URL const& url, StringVie return static_cast(**m_open_cache_entries.get(address)); } +void DiskCache::clear_cache() +{ + for (auto& [_, cache_entry] : m_open_cache_entries) + cache_entry->mark_for_deletion({}); + + m_index.remove_all_entries(); + + Core::DirIterator it { m_cache_directory.string(), Core::DirIterator::SkipDots }; + size_t cache_entries { 0 }; + + while (it.has_next()) { + auto entry = it.next_full_path(); + if (LexicalPath { entry }.title() == INDEX_DATABASE) + continue; + + (void)FileSystem::remove(entry, FileSystem::RecursionMode::Disallowed); + ++cache_entries; + } + + dbgln("Cleared {} disk cache entries", cache_entries); +} + void DiskCache::cache_entry_closed(Badge, CacheEntry const& cache_entry) { auto address = reinterpret_cast(&cache_entry); diff --git a/Services/RequestServer/Cache/DiskCache.h b/Services/RequestServer/Cache/DiskCache.h index 09cdc42e48d..9f21dd1ae8b 100644 --- a/Services/RequestServer/Cache/DiskCache.h +++ b/Services/RequestServer/Cache/DiskCache.h @@ -26,6 +26,7 @@ public: Optional create_entry(URL::URL const&, StringView method, u32 status_code, Optional reason_phrase, HTTP::HeaderMap const&, UnixDateTime request_time); Optional open_entry(URL::URL const&, StringView method); + void clear_cache(); LexicalPath const& cache_directory() { return m_cache_directory; } diff --git a/Services/RequestServer/ConnectionFromClient.cpp b/Services/RequestServer/ConnectionFromClient.cpp index 6461ce88954..95583e84509 100644 --- a/Services/RequestServer/ConnectionFromClient.cpp +++ b/Services/RequestServer/ConnectionFromClient.cpp @@ -861,6 +861,12 @@ void ConnectionFromClient::ensure_connection(URL::URL url, ::RequestServer::Cach } } +void ConnectionFromClient::clear_cache() +{ + if (g_disk_cache.has_value()) + g_disk_cache->clear_cache(); +} + void ConnectionFromClient::websocket_connect(i64 websocket_id, URL::URL url, ByteString origin, Vector protocols, Vector extensions, HTTP::HeaderMap additional_request_headers) { auto host = url.serialized_host().to_byte_string(); diff --git a/Services/RequestServer/ConnectionFromClient.h b/Services/RequestServer/ConnectionFromClient.h index 0a6baaa1618..7eafa4f1b0f 100644 --- a/Services/RequestServer/ConnectionFromClient.h +++ b/Services/RequestServer/ConnectionFromClient.h @@ -49,6 +49,8 @@ private: virtual Messages::RequestServer::SetCertificateResponse set_certificate(i32, ByteString, ByteString) override; virtual void ensure_connection(URL::URL url, ::RequestServer::CacheLevel cache_level) override; + virtual void clear_cache() override; + virtual void websocket_connect(i64 websocket_id, URL::URL, ByteString, Vector, Vector, HTTP::HeaderMap) override; virtual void websocket_send(i64 websocket_id, bool, ByteBuffer) override; virtual void websocket_close(i64 websocket_id, u16, ByteString) override; diff --git a/Services/RequestServer/RequestServer.ipc b/Services/RequestServer/RequestServer.ipc index ea4979d66b4..848efc91a97 100644 --- a/Services/RequestServer/RequestServer.ipc +++ b/Services/RequestServer/RequestServer.ipc @@ -22,6 +22,8 @@ endpoint RequestServer ensure_connection(URL::URL url, ::RequestServer::CacheLevel cache_level) =| + clear_cache() =| + // Websocket Connection API websocket_connect(i64 websocket_id, URL::URL url, ByteString origin, Vector protocols, Vector extensions, HTTP::HeaderMap additional_request_headers) =| websocket_send(i64 websocket_id, bool is_text, ByteBuffer data) =| From 21932661c27f472c5cb3f8bbf74b21245a7b5fd0 Mon Sep 17 00:00:00 2001 From: Jelle Raaijmakers Date: Tue, 14 Oct 2025 13:38:38 +0200 Subject: [PATCH 22/27] CI: Update .github/actions/ folder to latest when building older commits Older commits also have older setup code, such as requiring older XCode versions - which fails on newer macOS systems. Let's always use the latest custom actions so we can retroactively support these older builds. --- .github/workflows/js-and-wasm-artifacts.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/js-and-wasm-artifacts.yml b/.github/workflows/js-and-wasm-artifacts.yml index 2109a8e5997..d7a62f749f4 100644 --- a/.github/workflows/js-and-wasm-artifacts.yml +++ b/.github/workflows/js-and-wasm-artifacts.yml @@ -55,6 +55,13 @@ jobs: run: | echo "sha=$(git rev-parse HEAD)" >> "${GITHUB_OUTPUT}" + - name: 'Use latest custom actions' + if: ${{ inputs.reference_to_build != '' && github.sha != steps.build-commit.outputs.sha }} + shell: bash + run: | + git fetch origin master + git checkout origin/master -- .github/actions + - name: "Set up environment" uses: ./.github/actions/setup with: From 8fffce07df6a54e4c272c9aab3094bd69a5096ad Mon Sep 17 00:00:00 2001 From: Sam Atkins Date: Mon, 13 Oct 2025 14:03:45 +0100 Subject: [PATCH 23/27] LibWeb/CSS: Remove unused GenericShorthands.h include --- Libraries/LibWeb/CSS/PropertyNameAndID.h | 1 - 1 file changed, 1 deletion(-) diff --git a/Libraries/LibWeb/CSS/PropertyNameAndID.h b/Libraries/LibWeb/CSS/PropertyNameAndID.h index c12a8743672..6face41697d 100644 --- a/Libraries/LibWeb/CSS/PropertyNameAndID.h +++ b/Libraries/LibWeb/CSS/PropertyNameAndID.h @@ -7,7 +7,6 @@ #pragma once #include -#include #include #include #include From 65ba5acf9d2024f9576c1aa4eef1fba15434334a Mon Sep 17 00:00:00 2001 From: Sam Atkins Date: Mon, 13 Oct 2025 15:03:53 +0100 Subject: [PATCH 24/27] Tests: Import `@property { syntax }` parsing test This gets some extra passes with the next commit. --- .../register-property-syntax-parsing.txt | 250 ++++++++++++++++ .../register-property-syntax-parsing.html | 283 ++++++++++++++++++ 2 files changed, 533 insertions(+) create mode 100644 Tests/LibWeb/Text/expected/wpt-import/css/css-properties-values-api/register-property-syntax-parsing.txt create mode 100644 Tests/LibWeb/Text/input/wpt-import/css/css-properties-values-api/register-property-syntax-parsing.html diff --git a/Tests/LibWeb/Text/expected/wpt-import/css/css-properties-values-api/register-property-syntax-parsing.txt b/Tests/LibWeb/Text/expected/wpt-import/css/css-properties-values-api/register-property-syntax-parsing.txt new file mode 100644 index 00000000000..0fed46f2d4b --- /dev/null +++ b/Tests/LibWeb/Text/expected/wpt-import/css/css-properties-values-api/register-property-syntax-parsing.txt @@ -0,0 +1,250 @@ +Harness status: OK + +Found 239 tests + +177 Pass +62 Fail +Pass syntax:'*', initialValue:'a' is valid +Pass syntax:' * ', initialValue:'b' is valid +Pass syntax:'', initialValue:'2px' is valid +Pass syntax:' ', initialValue:'5' is valid +Pass syntax:' ', initialValue:'10%' is valid +Pass syntax:'+', initialValue:'red' is valid +Pass syntax:' + | ', initialValue:'2px 8px' is valid +Pass syntax:' + | #', initialValue:'red, blue' is valid +Pass syntax:'||', initialValue:'2px' is valid +Pass syntax:' | | | | ', initialValue:'red' is valid +Pass syntax:'