Skip to content

Commit 2082c73

Browse files
committed
Reuse Ruby builds when static gem extensions are unchanged
1 parent 58a51e1 commit 2082c73

6 files changed

Lines changed: 211 additions & 14 deletions

File tree

‎CONTRIBUTING.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ $ rake npm:ruby-head-wasm-wasip2:check
3434

3535
If you need to re-build Ruby, please clean `./rubies` directory, and run `rake npm:ruby-head-wasm-wasi` again.
3636

37+
`rake npm:ruby-head-wasm-wasi` reuses Ruby unless its statically linked gem
38+
extensions change.
39+
40+
After changing the compiler or build settings, also discard intermediate
41+
objects with `rake npm:ruby-head-wasm-wasi:clean-build`.
42+
3743
### npm package test structure
3844

3945
The `ruby-wasm-wasi` package contains the shared JavaScript bindings and test suites, but it does not contain a Ruby WebAssembly binary. Version-specific packages provide the binary and invoke the shared tests with `RUBY_NPM_PACKAGE_ROOT`:

‎lib/ruby_wasm/packager/core.rb‎

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
require "forwardable"
22
require "pathname"
3+
require_relative "extension_cache"
34

45
class RubyWasm::Packager::Core
56
def initialize(packager)
@@ -281,22 +282,23 @@ def name
281282
class StaticLinking < BuildStrategy
282283
def build(executor, options)
283284
build = derive_build
285+
cache =
286+
RubyWasm::Packager::ExtensionCache.new(
287+
build.crossruby.artifact,
288+
build.crossruby.user_exts
289+
)
284290
force_rebuild =
285291
options[:remake] || options[:clean] || options[:reconfigure]
286-
if File.exist?(build.crossruby.artifact) && !force_rebuild
287-
return build.crossruby.artifact
288-
end
289-
build.crossruby.clean(executor) if options[:clean]
290-
291-
self.with_unbundled_env do
292-
build.crossruby.build(
293-
executor,
294-
remake: options[:remake],
295-
reconfigure: options[:reconfigure]
296-
)
292+
cache.fetch(force: force_rebuild) do
293+
build.crossruby.clean(executor) if options[:clean]
294+
with_unbundled_env do
295+
build.crossruby.build(
296+
executor,
297+
remake: true,
298+
reconfigure: options[:reconfigure]
299+
)
300+
end
297301
end
298-
299-
build.crossruby.artifact
300302
end
301303

302304
def cache_key(digest)
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
require "digest"
2+
require "fileutils"
3+
4+
class RubyWasm::Packager::ExtensionCache
5+
def initialize(artifact, extensions)
6+
@artifact = artifact
7+
@extensions = extensions
8+
@record = "#{artifact}.extensions.sha256"
9+
end
10+
11+
# Inputs may be edited during a build, so only record builds whose inputs
12+
# stayed unchanged. Invalidate the previous record first so a failed forced
13+
# rebuild cannot leave an older artifact eligible for reuse.
14+
def fetch(force: false)
15+
extensions_key_before_build = key
16+
if !force && reusable?(extensions_key_before_build)
17+
RubyWasm.logger.info "Reusing Ruby: #{@artifact}"
18+
return @artifact
19+
end
20+
21+
FileUtils.rm_f(@record)
22+
yield
23+
24+
extensions_key_after_build = key
25+
if extensions_key_before_build == extensions_key_after_build
26+
File.write(@record, extensions_key_after_build)
27+
end
28+
@artifact
29+
end
30+
31+
private
32+
33+
def reusable?(key)
34+
return false unless File.file?(@artifact)
35+
return true if @extensions.empty?
36+
37+
File.file?(@record) && File.read(@record) == key
38+
end
39+
40+
def key
41+
digest = Digest::SHA256.new
42+
@extensions.each { |ext| ext.cache_key(digest) }
43+
digest.hexdigest
44+
end
45+
end

‎rakelib/packaging.rake‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ def build_ruby(pkg, base_dir, pkg_dir, binaryen, clean: false)
105105
sh env,
106106
"bundle", "exec",
107107
*build_command,
108-
"--no-stdlib", "--remake",
108+
"--no-stdlib",
109109
*(clean ? ["--clean"] : []),
110110
"-o",
111111
File.join(dist_dir, "ruby.wasm")

‎sig/ruby_wasm/packager.rbs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,16 @@ class RubyWasm::Packager
9494
end
9595
end
9696

97+
class ExtensionCache
98+
@artifact: String
99+
@extensions: Array[RubyWasm::CrossRubyExtProduct]
100+
@record: String
101+
def initialize: (String artifact, Array[RubyWasm::CrossRubyExtProduct] extensions) -> void
102+
def fetch: (?force: bool?) { () -> void } -> String
103+
private def reusable?: (String key) -> bool
104+
private def key: () -> String
105+
end
106+
97107
class FileSystem
98108
@dest_dir: string
99109
@packager: RubyWasm::Packager
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
require "test-unit"
2+
require "tmpdir"
3+
require_relative "../lib/ruby_wasm"
4+
5+
class StaticExtensionCacheTest < Test::Unit::TestCase
6+
class CrossRuby
7+
attr_reader :artifact, :calls, :extension, :user_exts, :clean_count
8+
attr_accessor :fail_build, :during_build
9+
10+
def initialize(dir)
11+
@artifact = File.join(dir, "ruby.tar.gz")
12+
@extension = File.join(dir, "extension")
13+
FileUtils.mkdir_p(@extension)
14+
File.write(File.join(@extension, "js.c"), "original")
15+
@user_exts = [
16+
RubyWasm::CrossRubyExtProduct.new(@extension, nil, features: nil)
17+
]
18+
@calls = []
19+
@clean_count = 0
20+
end
21+
22+
def build(_executor, **options)
23+
@calls << options
24+
@during_build.call if @during_build
25+
File.write(artifact, "ruby")
26+
raise "build failed" if fail_build
27+
end
28+
29+
def clean(_executor)
30+
@clean_count += 1
31+
FileUtils.rm_f(artifact)
32+
end
33+
end
34+
35+
class Strategy < RubyWasm::Packager::Core::StaticLinking
36+
def initialize(build)
37+
@build = build
38+
end
39+
40+
def derive_build
41+
@build
42+
end
43+
end
44+
45+
def setup
46+
@dir = Dir.mktmpdir
47+
@build = Struct.new(:crossruby).new(CrossRuby.new(@dir))
48+
@strategy = Strategy.new(@build)
49+
@executor = Object.new
50+
end
51+
52+
def teardown
53+
FileUtils.remove_entry(@dir)
54+
end
55+
56+
def build(**options)
57+
@strategy.build(@executor, options)
58+
end
59+
60+
def test_unchanged_extensions_reuse_artifact
61+
build
62+
build
63+
assert_equal 1, @build.crossruby.calls.size
64+
end
65+
66+
def test_extension_content_change_rebuilds
67+
build
68+
path = File.join(@build.crossruby.extension, "js.c")
69+
File.write(path, "modified")
70+
build
71+
assert_equal 2, @build.crossruby.calls.size
72+
assert_equal(
73+
{ remake: true, reconfigure: nil },
74+
@build.crossruby.calls.last
75+
)
76+
assert_equal 0, @build.crossruby.clean_count
77+
end
78+
79+
def test_added_and_deleted_extension_inputs_rebuild
80+
build
81+
path = File.join(@build.crossruby.extension, "extra.h")
82+
File.write(path, "header")
83+
build
84+
FileUtils.rm_f(path)
85+
build
86+
assert_equal 3, @build.crossruby.calls.size
87+
end
88+
89+
def test_existing_artifact_without_record_rebuilds
90+
File.write(@build.crossruby.artifact, "old build")
91+
build
92+
assert_equal 1, @build.crossruby.calls.size
93+
end
94+
95+
def test_existing_artifact_without_extensions_is_reused
96+
@build.crossruby.user_exts.clear
97+
File.write(@build.crossruby.artifact, "prebuilt Ruby")
98+
build
99+
assert_empty @build.crossruby.calls
100+
end
101+
102+
def test_force_options_rebuild_even_with_matching_inputs
103+
build
104+
build(remake: true)
105+
build(reconfigure: true)
106+
build(clean: true)
107+
assert_equal 4, @build.crossruby.calls.count { |call| call.is_a?(Hash) }
108+
assert_equal 1, @build.crossruby.clean_count
109+
assert_equal true, @build.crossruby.calls[2][:reconfigure]
110+
end
111+
112+
def test_failed_forced_build_invalidates_previous_record
113+
build
114+
@build.crossruby.fail_build = true
115+
assert_raise(RuntimeError) { build(remake: true) }
116+
@build.crossruby.fail_build = false
117+
build
118+
assert_equal 3, @build.crossruby.calls.size
119+
end
120+
121+
def test_inputs_edited_during_build_are_not_recorded
122+
@build.crossruby.during_build =
123+
proc do
124+
File.write(
125+
File.join(@build.crossruby.extension, "js.c"),
126+
"edited during build"
127+
)
128+
end
129+
build
130+
@build.crossruby.during_build = nil
131+
build
132+
assert_equal 2, @build.crossruby.calls.size
133+
end
134+
end

0 commit comments

Comments
 (0)