Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions docs/src/guide/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ automatically when the asset is requested.
This is very convenient when running integration tests, or when a developer
does not want to start the Vite development server (at the expense of a slower feedback loop).

When tests run in parallel processes, builds and manifest reads are synchronized
across workers. The first worker builds stale assets while the others wait and
reuse its result.

::: tip Enabled locally
By [default][json config], <kbd>[autoBuild]</kbd> is enabled in the `test` and `development` environments.
:::
Expand Down Expand Up @@ -178,10 +182,14 @@ When running tests locally, you can test the production build by not starting th
When running tests in the CI, it's more reliable if assets are available
__before__ tests start to run, as it:

- Prevents timeouts in Capybara during <kbd>[autoBuild]</kbd>
- Prevents race conditions when running tests in parallel (each thread could start a build)
- Prevents timeouts in Capybara while waiting for <kbd>[autoBuild]</kbd>
- Verifies the production asset build independently from the test suite

Parallel test workers are synchronized when using <kbd>[autoBuild]</kbd>, so
precompilation is not required for correctness. It remains recommended in CI
for predictable test startup and build failures.

To achieve that, it's recommended to run `bin/rake assets:precompile`—which should [also run `vite build`][deployment]—in a previous CI step.
To achieve that, run `bin/rake assets:precompile`—which should [also run `vite build`][deployment]—in a previous CI step.

You can verify your setup is working by disabling <kbd>[autoBuild]</kbd>. A convenient way to do that is to add `VITE_RUBY_AUTO_BUILD="false"` to the build environment variables.

Expand Down
39 changes: 39 additions & 0 deletions test/builder_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ def test_last_build_path
assert_equal builder.send(:last_build_path, ssr: true).basename.to_s, "last-ssr-build-#{ViteRuby.config.mode}.json"
end

def test_build_is_synchronized_between_processes
skip "Process.fork is not supported" unless Process.respond_to?(:fork)

assert_build_is_synchronized_between_processes
end

def test_watched_files_digest
previous_digest = ViteRuby.digest
refresh_config
Expand Down Expand Up @@ -158,6 +164,39 @@ def test_build_cache

private

def assert_build_is_synchronized_between_processes
build_count_path = ViteRuby.config.build_cache_dir.join("build-count")
build_count_path.dirname.mkpath
reader, writer = IO.pipe
result = ["stdout", "", MockProcessStatus.new(success: true)]

ViteRuby::IO.stub(:capture, ->(*) {
build_count_path.open("a") { |file| file.puts Process.pid }
sleep 0.2
result
}) do
pids = Array.new(2) do
Process.fork do
writer.close
reader.read(1)
exit! builder.build ? 0 : 1
end
end

reader.close
2.times { writer.write("1") }
writer.close

assert pids.map { |pid| Process.wait2(pid).last }.all?(&:success?)
end

assert_equal 1, build_count_path.readlines.size
ensure
reader&.close unless reader&.closed?
writer&.close unless writer&.closed?
build_count_path&.delete if build_count_path&.exist?
end

def watched_file
Pathname.new(path_to_test_app).join(WATCHED_FILE)
end
Expand Down
50 changes: 50 additions & 0 deletions test/manifest_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,16 @@ def test_lookup_with_type_exception!
assert_match "Vite Ruby can't find entrypoints/#{asset_file}.js in the manifests", error.message
end

def test_lookup_does_not_initialize_build_dependencies_when_auto_build_is_disabled
refute ViteRuby.instance.instance_variable_defined?(:@builder)
refute ViteRuby.instance.instance_variable_defined?(:@build_lock)

assert_equal prefixed("app.517bf154.css"), path_for("app", type: :stylesheet)

refute ViteRuby.instance.instance_variable_defined?(:@builder)
refute ViteRuby.instance.instance_variable_defined?(:@build_lock)
end

def test_lookup_success!
vendor_chunk = {
"file" => prefixed("vendor.0f7c0ec3.js"),
Expand Down Expand Up @@ -263,6 +273,12 @@ def test_skip_proxy_has_no_effect_without_dev_server
assert_equal prefixed("logo.f42fb7ea.png"), path_for("images/logo.png")
end

def test_manifest_read_waits_for_build_in_another_process
skip "Process.fork is not supported" unless Process.respond_to?(:fork)

assert_manifest_read_waits_for_build_in_another_process
end

def test_lookup_nil
assert_nil lookup("foo.js")
end
Expand Down Expand Up @@ -302,6 +318,40 @@ def test_lookup_success

private

def assert_manifest_read_waits_for_build_in_another_process
refresh_config(auto_build: true)
path = ViteRuby.config.manifest_paths.first
contents = path.read
reader, writer = IO.pipe

pid = Process.fork do
reader.close
ViteRuby.instance.build_lock.synchronize(File::LOCK_EX) do
path.write("")
writer.write("1")
writer.close
sleep 0.2
path.write(contents)
end
exit! 0
end

writer.close
reader.read(1)

assert ViteRuby.instance.manifest.refresh
assert_predicate Process.wait2(pid).last, :success?
ensure
reader&.close unless reader&.closed?
writer&.close unless writer&.closed?
path&.write(contents) if path && contents
begin
Process.wait(pid) if pid
rescue Errno::ECHILD
nil
end
end

def assert_raises_manifest_missing_entry_error(auto_build: false, &block)
error = nil

Expand Down
5 changes: 5 additions & 0 deletions vite_ruby/lib/vite_ruby.rb
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,11 @@ def run(argv, **options)
(@runner ||= ViteRuby::Runner.new(self)).run(argv, **options)
end

# Internal: Synchronizes Vite builds and manifest reads.
def build_lock
@build_lock ||= ViteRuby::BuildLock.new(self)
end

# Public: Keeps track of watched files and triggers builds as needed.
def builder
@builder ||= ViteRuby::Builder.new(self)
Expand Down
31 changes: 31 additions & 0 deletions vite_ruby/lib/vite_ruby/build_lock.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# frozen_string_literal: true

# Internal: Synchronizes Vite builds and manifest reads across threads and processes.
class ViteRuby::BuildLock
def initialize(vite_ruby)
@vite_ruby = vite_ruby
@mutex = Mutex.new
end

def synchronize(mode)
@mutex.synchronize do
lock_path.dirname.mkpath
lock_path.open(File::RDWR | File::CREAT, 0o644) do |lock|
lock.flock(mode)
yield
end
end
end

private

extend Forwardable

def_delegator :@vite_ruby, :config

# The lock is kept outside the build cache so clobbering the cache cannot
# replace the locked file while another process is waiting on it.
def lock_path
Pathname.new("#{config.build_cache_dir}.lock")
end
end
30 changes: 16 additions & 14 deletions vite_ruby/lib/vite_ruby/builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,21 @@ def initialize(vite_ruby)
# Public: Checks if the watched files have changed since the last compilation,
# and triggers a Vite build if any files have changed.
def build(*args)
last_build = last_build_metadata(ssr: args.include?("--ssr"))

if args.delete("--force") || last_build.stale? || config.manifest_paths.empty?
stdout, stderr, status = build_with_vite(*args)
log_build_result(stdout, stderr, status)
record_build_metadata(last_build, errors: stderr, success: status.success?)
status.success?
elsif last_build.success
logger.debug "Skipping vite build. Watched files have not changed since the last build at #{last_build.timestamp}"
true
else
logger.error "Skipping vite build. Watched files have not changed since the build failed at #{last_build.timestamp} ❌"
false
build_lock.synchronize(File::LOCK_EX) do
last_build = last_build_metadata(ssr: args.include?("--ssr"))

if args.delete("--force") || last_build.stale? || config.manifest_paths.empty?
stdout, stderr, status = build_with_vite(*args)
log_build_result(stdout, stderr, status)
record_build_metadata(last_build, errors: stderr, success: status.success?)
status.success?
elsif last_build.success
logger.debug "Skipping vite build. Watched files have not changed since the last build at #{last_build.timestamp}"
true
else
logger.error "Skipping vite build. Watched files have not changed since the build failed at #{last_build.timestamp} ❌"
false
end
end
end

Expand All @@ -37,7 +39,7 @@ def last_build_metadata(ssr: false)

extend Forwardable

def_delegators :@vite_ruby, :config, :logger, :run
def_delegators :@vite_ruby, :build_lock, :config, :logger, :run

# Internal: Writes a digest of the watched files to disk for future checks.
def record_build_metadata(build, **attrs)
Expand Down
13 changes: 10 additions & 3 deletions vite_ruby/lib/vite_ruby/manifest.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
class ViteRuby::Manifest
def initialize(vite_ruby)
@vite_ruby = vite_ruby
@build_mutex = Mutex.new if config.auto_build
end

# Public: Returns the path for the specified Vite entrypoint file.
Expand Down Expand Up @@ -104,7 +103,9 @@ def lookup!(name, **options)
# manifest.lookup('calendar.js')
# => { "file" => "/vite/assets/calendar-1016838bab065ae1e122.js", "imports" => [] }
def lookup(name, **options)
@build_mutex.synchronize { builder.build || (return nil) } if should_build?
if should_build?
return unless builder.build
end

find_manifest_entry resolve_entry_name(name, **options)
end
Expand All @@ -116,7 +117,7 @@ def lookup(name, **options)

extend Forwardable

def_delegators :@vite_ruby, :config, :builder, :dev_server_running?
def_delegators :@vite_ruby, :build_lock, :config, :builder, :dev_server_running?

# NOTE: Auto compilation is convenient when running tests, when the developer
# won't focus on the frontend, or when running the Vite server is not desired.
Expand Down Expand Up @@ -145,6 +146,12 @@ def manifest

# Internal: Loads and merges the manifest files, resolving the asset paths.
def load_manifest
return read_manifest unless config.auto_build

build_lock.synchronize(File::LOCK_SH) { read_manifest }
end

def read_manifest
config.manifest_paths
.map { |path| JSON.parse(path.read) }
.inject({}, &:merge)
Expand Down
Loading