Skip to content

Commit fe3a757

Browse files
authored
chore(ruby): Add CI workflow to run mock server tests (#603)
* chore(ruby): Add CI workflow to run mock server tests * fix minitest after run hook to handle failing tests
1 parent 5a158c3 commit fe3a757

File tree

5 files changed

+200
-86
lines changed

5 files changed

+200
-86
lines changed
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
name: Ruby Wrapper Tests
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
pull_request:
8+
9+
jobs:
10+
test:
11+
name: Test ${{ matrix.os }} (Ruby ${{ matrix.ruby-version }})
12+
runs-on: ${{ matrix.os }}
13+
strategy:
14+
fail-fast: false
15+
matrix:
16+
os: [ubuntu-latest, macos-latest, windows-latest]
17+
ruby-version: ['3.3']
18+
19+
defaults:
20+
run:
21+
shell: bash
22+
working-directory: ./spannerlib/wrappers/spannerlib-ruby
23+
24+
steps:
25+
- name: Checkout code
26+
uses: actions/checkout@v4
27+
28+
- name: Set up Go
29+
uses: actions/setup-go@v5
30+
with:
31+
go-version: '1.25.x'
32+
33+
- name: Set up Ruby
34+
uses: ruby/setup-ruby@v1
35+
with:
36+
ruby-version: ${{ matrix.ruby-version }}
37+
bundler-cache: true
38+
working-directory: ./spannerlib/wrappers/spannerlib-ruby
39+
40+
- name: Build shared library (Linux)
41+
if: runner.os == 'Linux'
42+
run: bundle exec rake compile:x86_64-linux
43+
44+
- name: Build shared library (macOS)
45+
if: runner.os == 'macOS'
46+
run: |
47+
ARCH=$(uname -m)
48+
if [ "$ARCH" == "arm64" ]; then
49+
bundle exec rake compile:aarch64-darwin
50+
else
51+
bundle exec rake compile:x86_64-darwin
52+
fi
53+
- name: Build shared library (Windows)
54+
if: runner.os == 'Windows'
55+
run: |
56+
CC=gcc CXX=g++ AR=ar RANLIB=ranlib bundle exec rake compile:x64-mingw32
57+
58+
- name: Run Tests
59+
run: |
60+
bundle exec ruby -Ilib -Ispec spec/spannerlib_ruby_spec.rb

spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/ffi.rb

Lines changed: 18 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,9 @@
2828
module SpannerLib
2929
extend FFI::Library
3030

31-
ENV_OVERRIDE = ENV.fetch("SPANNERLIB_PATH", nil)
32-
31+
# rubocop:disable Metrics/MethodLength
3332
def self.platform_dir_from_host
34-
host_os = RbConfig::CONFIG["host_os"]
33+
host_os = RbConfig::CONFIG["host_os"]
3534
host_cpu = RbConfig::CONFIG["host_cpu"]
3635

3736
case host_os
@@ -41,16 +40,19 @@ def self.platform_dir_from_host
4140
host_cpu =~ /arm|aarch64/ ? "aarch64-linux" : "x86_64-linux"
4241
when /mswin|mingw|cygwin/
4342
"x64-mingw32"
43+
else
44+
raise "Unknown OS: #{host_os}"
4445
end
4546
end
47+
# rubocop:enable Metrics/MethodLength
4648

47-
# Build list of candidate paths (ordered): env override, platform-specific, any packaged lib, system library
48-
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
49+
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity
4950
def self.library_path
50-
if ENV_OVERRIDE && !ENV_OVERRIDE.empty?
51-
return ENV_OVERRIDE if File.file?(ENV_OVERRIDE)
51+
env_path = ENV.fetch("SPANNERLIB_PATH", nil)
52+
if env_path && !env_path.empty?
53+
return env_path if File.file?(env_path)
5254

53-
warn "SPANNERLIB_PATH set to #{ENV_OVERRIDE} but file not found"
55+
warn "SPANNERLIB_PATH set to #{env_path} but file not found"
5456
end
5557

5658
lib_dir = File.expand_path(__dir__)
@@ -62,53 +64,38 @@ def self.library_path
6264
return candidate if File.exist?(candidate)
6365
end
6466

65-
# 3) Any matching packaged binary (first match)
6667
glob_candidates = Dir.glob(File.join(lib_dir, "*", "spannerlib.#{ext}"))
6768
return glob_candidates.first unless glob_candidates.empty?
6869

69-
# 4) Try loading system-wide library (so users who installed shared lib separately can use it)
7070
begin
71-
# Attempt to open system lib name; if succeeds, return bare name so ffi_lib can resolve it
7271
FFI::DynamicLibrary.open("spannerlib", FFI::DynamicLibrary::RTLD_LAZY | FFI::DynamicLibrary::RTLD_GLOBAL)
7372
return "spannerlib"
7473
rescue LoadError
75-
# This is intentional. If the system library fails to load,
76-
# we'll proceed to the final LoadError with all search paths.
74+
# Ignore
7775
end
7876

79-
searched = []
80-
searched << "ENV SPANNERLIB_PATH=#{ENV_OVERRIDE}" if ENV_OVERRIDE && !ENV_OVERRIDE.empty?
81-
searched << File.join(lib_dir, platform || "<detected-platform?>", "spannerlib.#{ext}")
82-
searched << File.join(lib_dir, "*", "spannerlib.#{ext}")
83-
84-
raise LoadError, <<~ERR
85-
Could not locate the spannerlib native library. Tried:
86-
- #{searched.join("\n - ")}
87-
If you are using the packaged gem, ensure the gem includes lib/spannerlib/<platform>/spannerlib.#{ext}.
88-
You can set SPANNERLIB_PATH to the absolute path of the library file, or install a platform-specific native gem.
89-
ERR
77+
raise LoadError, "Could not locate native library. Checked: #{File.join(lib_dir, platform.to_s)}"
9078
end
91-
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
92-
79+
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity
9380
ffi_lib library_path
9481

9582
class GoString < FFI::Struct
9683
layout :p, :pointer,
97-
:len, :long
84+
:len, :long_long
9885
end
9986

10087
# GoBytes is the Ruby representation of a Go byte slice
10188
class GoBytes < FFI::Struct
10289
layout :p, :pointer,
103-
:len, :long,
104-
:cap, :long
90+
:len, :long_long,
91+
:cap, :long_long
10592
end
10693

10794
# Message is the common return type for all native functions.
10895
class Message < FFI::Struct
10996
layout :pinner, :long_long,
11097
:code, :int,
111-
:objectId, :long_long,
98+
:remote_id, :long_long,
11299
:length, :int,
113100
:pointer, :pointer
114101
end
@@ -188,7 +175,7 @@ def self.ensure_release(message)
188175

189176
def self.handle_object_id_response(message, _func_name)
190177
ensure_release(message) do
191-
MessageHandler.new(message).object_id
178+
MessageHandler.new(message).remote_id
192179
end
193180
end
194181

spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/message_handler.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ def initialize(message)
2424
@message = message
2525
end
2626

27-
def object_id
27+
def remote_id
2828
throw_if_error!
29-
@message[:objectId]
29+
@message[:remote_id]
3030
end
3131

3232
# Returns the data payload from the message.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# frozen_string_literal: true
2+
3+
# Copyright 2025 Google LLC
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# https://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
$stdout.sync = true
18+
19+
require "grpc"
20+
require "google/cloud/spanner/v1/spanner"
21+
require "google/spanner/v1/spanner_pb"
22+
require "google/spanner/v1/spanner_services_pb"
23+
24+
require_relative "mock_server/spanner_mock_server"
25+
26+
Signal.trap("TERM") do
27+
exit!(0) # "exit skips cleanup hooks and prevents gRPC segfaults
28+
end
29+
30+
begin
31+
server = GRPC::RpcServer.new
32+
port = server.add_http2_port "127.0.0.1:0", :this_port_is_insecure
33+
server.handle SpannerMockServer.new
34+
File.write(ENV["MOCK_PORT_FILE"], port.to_s) if ENV["MOCK_PORT_FILE"]
35+
36+
# 2. Print ONLY the port number to stdout for the parent to read
37+
puts port
38+
server.run_till_terminated
39+
rescue SignalException
40+
exit(0)
41+
rescue StandardError => e
42+
warn "Mock server crashed: #{e.message}"
43+
warn e.backtrace.join("\n")
44+
exit(1)
45+
end
Lines changed: 75 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# frozen_string_literal: true
22

3+
# rubocop:disable Style/GlobalVars
34
# rubocop:disable RSpec/NoExpectationExample
45

56
# Copyright 2025 Google LLC
@@ -17,57 +18,87 @@
1718
# limitations under the License.
1819

1920
require "minitest/autorun"
20-
21-
require "grpc"
22-
require "google/rpc/error_details_pb"
23-
require "google/spanner/v1/spanner_pb"
24-
require "google/spanner/v1/spanner_services_pb"
2521
require "google/cloud/spanner/v1/spanner"
26-
27-
require_relative "mock_server/statement_result"
28-
require_relative "mock_server/spanner_mock_server"
22+
require "timeout"
23+
require "tmpdir" # Needed for the file-based handshake
2924

3025
require_relative "../lib/spannerlib/ffi"
3126
require_relative "../lib/spannerlib/connection"
32-
require_relative "../lib/spannerlib/rows"
33-
34-
READ_PIPE, WRITE_PIPE = IO.pipe
35-
GRPC.prefork
36-
37-
SERVER_PID = fork do
38-
GRPC.postfork_child
39-
READ_PIPE.close
40-
$stdout.sync = true
41-
$stderr.sync = true
42-
43-
begin
44-
server = GRPC::RpcServer.new
45-
port = server.add_http2_port "localhost:0", :this_port_is_insecure
46-
mock = SpannerMockServer.new
47-
server.handle mock
48-
49-
WRITE_PIPE.puts port
50-
WRITE_PIPE.close
51-
52-
server.run
53-
rescue StandardError => e
54-
warn "Mock server failed to start: #{e.message}"
55-
exit(1)
27+
28+
$server_pid = nil
29+
$server_port = nil
30+
$port_file = nil
31+
$any_failure = false
32+
33+
Minitest.after_run do
34+
if $server_pid
35+
begin
36+
Process.kill("TERM", $server_pid)
37+
Process.wait($server_pid)
38+
rescue Errno::ESRCH, Errno::ECHILD, Errno::EINVAL
39+
# Process already dead
40+
end
41+
end
42+
File.delete($port_file) if $port_file && File.exist?($port_file)
43+
if $any_failure
44+
puts "Tests Failed! Exiting with code 1"
45+
$stdout.flush
46+
Process.exit!(1)
47+
else
48+
puts "Tests Passed! Exiting with code 0"
49+
$stdout.flush
50+
Process.exit!(0)
5651
end
5752
end
5853

59-
GRPC.postfork_parent
60-
WRITE_PIPE.close
54+
describe "Connection" do
55+
def self.spawn_server
56+
runner_path = File.expand_path(File.join(__dir__, "mock_server_runner.rb"))
6157

62-
SERVER_PORT = READ_PIPE.gets.strip.to_i
63-
READ_PIPE.close
58+
$port_file = File.join(Dir.tmpdir, "spanner_mock_port_#{Time.now.to_i}_#{rand(1000)}.txt")
6459

65-
describe "Connection" do
66-
before do
67-
server_address = "localhost:#{SERVER_PORT}"
68-
database_path = "projects/p/instances/i/databases/d"
60+
# 2. Tell the runner where to write the port
61+
env_vars = { "MOCK_PORT_FILE" => $port_file }
62+
63+
# 3. Spawn process inheriting stdout/stderr.
64+
# This prevents the buffer deadlock.
65+
Process.spawn(env_vars, RbConfig.ruby, "-Ilib", "-Ispec", runner_path, out: $stdout, err: $stderr)
66+
end
67+
68+
def self.wait_for_port
69+
start_time = Time.now
70+
loop do
71+
raise "Timed out waiting for mock server to write to #{$port_file}" if Time.now - start_time > 20
72+
73+
# 4. Poll the file for the port
74+
if File.exist?($port_file)
75+
content = File.read($port_file).strip
76+
return content.to_i unless content.empty?
77+
end
78+
79+
# Check if the server died
80+
raise "Mock server exited unexpectedly!" if Process.waitpid($server_pid, Process::WNOHANG)
6981

70-
@dsn = "#{server_address}/#{database_path}?useplaintext=true"
82+
sleep 0.1
83+
end
84+
end
85+
86+
def self.ensure_server_running!
87+
return if $server_port
88+
89+
$server_pid = spawn_server
90+
91+
begin
92+
$server_port = wait_for_port
93+
rescue StandardError
94+
Process.kill("TERM", $server_pid) if $server_pid
95+
raise
96+
end
97+
end
98+
99+
before do
100+
self.class.ensure_server_running!
101+
@dsn = "127.0.0.1:#{$server_port}/projects/p/instances/i/databases/d?useplaintext=true"
71102

72103
@pool_id = SpannerLib.create_pool(@dsn)
73104
@conn_id = SpannerLib.create_connection(@pool_id)
@@ -77,12 +108,11 @@
77108
after do
78109
@conn&.close
79110
SpannerLib.close_pool(@pool_id) if @pool_id
111+
$any_failure = true unless failures.empty?
80112
end
81113

82114
it "can execute SELECT 1" do
83-
request_proto = Google::Cloud::Spanner::V1::ExecuteSqlRequest.new(
84-
sql: "SELECT 1"
85-
)
115+
request_proto = Google::Cloud::Spanner::V1::ExecuteSqlRequest.new(sql: "SELECT 1")
86116
rows_object = @conn.execute(request_proto)
87117

88118
decoded_rows = rows_object.map do |row_bytes|
@@ -108,13 +138,5 @@
108138
_(commit_resp.commit_timestamp).wont_be_nil
109139
end
110140
end
111-
112141
# rubocop:enable RSpec/NoExpectationExample
113-
114-
# --- 5. GLOBAL SHUTDOWN HOOK ---
115-
Minitest.after_run do
116-
if SERVER_PID
117-
Process.kill("KILL", SERVER_PID)
118-
Process.wait(SERVER_PID)
119-
end
120-
end
142+
# rubocop:enable Style/GlobalVars

0 commit comments

Comments
 (0)