From 2887647c94a03be2f5e490dceb7ab9337d62beff Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 17:24:52 +0000 Subject: [PATCH] Keep flash messages in the session, not the store A store is shared by every request but held one Flash, so a message one visitor set for their next request was shown to whichever visitor made the next request. Flash was already serializable but was never saved. The flash now belongs to the session (Session::Base#flash), is saved with it by every store, and is rotated when the session it belongs to is loaded, so a message reaches only the visitor it was set for and lasts for exactly one of their requests. Store#flash returns the current session's. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01HT5gvYGR83U2M4DyA6NA6f --- spec/flash_isolation_spec.cr | 73 ++++++++++++++++++++++++++++++++++++ spec/store_spec.cr | 7 +++- src/base.cr | 4 ++ src/store.cr | 11 +++--- 4 files changed, 87 insertions(+), 8 deletions(-) create mode 100644 spec/flash_isolation_spec.cr diff --git a/spec/flash_isolation_spec.cr b/spec/flash_isolation_spec.cr new file mode 100644 index 0000000..2d4585c --- /dev/null +++ b/spec/flash_isolation_spec.cr @@ -0,0 +1,73 @@ +require "./spec_helper" + +# Reads the flash of the request it serves, then optionally sets one for the next request. +private class FlashProbe + include HTTP::Handler + + getter shown = [] of String? + property next_message : String? = nil + + def initialize(@store : Session::MemoryStore(UserSession)) + end + + def call(context : HTTP::Server::Context) + shown << @store.flash.now["notice"]? + @store.flash.next["notice"] = next_message.not_nil! if next_message + end +end + +private def request_for(session_id : String) : HTTP::Server::Context + request = HTTP::Request.new("GET", "/") + request.cookies << HTTP::Cookie.new(name: Session.config.session_key, value: session_id) + HTTP::Server::Context.new(request, HTTP::Server::Response.new(IO::Memory.new)) +end + +describe "flash messages" do + it "reach the session that set them, and nobody else's" do + reset_config + store = Session::MemoryStore(UserSession).new + Session.config.store = store + alice = UserSession.new + bob = UserSession.new + store[alice.session_id] = alice + store[bob.session_id] = bob + + probe = FlashProbe.new(store) + handler = Session::SessionHandler(UserSession).new(store) + handler.next = probe + + probe.next_message = "Alice's private notice" + handler.call(request_for(alice.session_id)) + probe.next_message = nil + handler.call(request_for(bob.session_id)) + handler.call(request_for(alice.session_id)) + + probe.shown.should eq [nil, nil, "Alice's private notice"] + end + + it "lasts for exactly one request" do + reset_config + store = Session::MemoryStore(UserSession).new + Session.config.store = store + alice = UserSession.new + store[alice.session_id] = alice + + probe = FlashProbe.new(store) + handler = Session::SessionHandler(UserSession).new(store) + handler.next = probe + + probe.next_message = "once" + handler.call(request_for(alice.session_id)) + probe.next_message = nil + 2.times { handler.call(request_for(alice.session_id)) } + + probe.shown.should eq [nil, "once", nil] + end + + it "is saved with the session, so a store that serializes it keeps it" do + session = UserSession.new + session.flash.next["notice"] = "saved" + + UserSession.from_json(session.to_json).flash.next["notice"].should eq "saved" + end +end diff --git a/spec/store_spec.cr b/spec/store_spec.cr index 98f5d14..7bad41c 100644 --- a/spec/store_spec.cr +++ b/spec/store_spec.cr @@ -73,11 +73,14 @@ describe Session::Store do store.flash.should be_a Session::Flash end - it "rotates flash on load" do + it "rotates the flash of the session it loads" do store = Session::MemoryStore(UserSession).new - store.flash["notice"] = "Hello" + session = UserSession.new + session.flash["notice"] = "Hello" + store[session.session_id] = session cookies = HTTP::Cookies.new + cookies << HTTP::Cookie.new(name: store.session_key, value: session.session_id) store.load_from(cookies) store.flash.now["notice"].should eq "Hello" diff --git a/src/base.cr b/src/base.cr index fb75a26..043a9af 100644 --- a/src/base.cr +++ b/src/base.cr @@ -23,6 +23,10 @@ module Session property expires_at : Time = Session.config.timeout.from_now + # Messages for the next request. They belong to the session, so they are saved with it + # and reach only the visitor they were meant for. + getter flash : Flash = Flash.new + def initialize end diff --git a/src/store.cr b/src/store.cr index 617101b..ad57ab2 100644 --- a/src/store.cr +++ b/src/store.cr @@ -57,11 +57,10 @@ module Session # Instance variables for session lifecycle @mutex : Mutex = Mutex.new @current_sessions = {} of Fiber => T - @flash : Flash = Flash.new - # Access flash messages + # The current request's flash messages (those of its session). def flash : Flash - @flash + current_session.flash end def session_id : String @@ -116,9 +115,6 @@ module Session end def load_from(request_cookies : HTTP::Cookies) : T? - # Rotate flash messages at the start of each request - @flash.rotate! - if self.is_a?(CookieStore(T)) self.as(CookieStore(T)).cookies = request_cookies end @@ -132,6 +128,9 @@ module Session current_session.touch end + # Messages set by the previous request become this request's + current_session.flash.rotate! + on(:loaded, session_id, current_session) end end