mimic-browser 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,57 @@
1
+ {
2
+ "release": "v0.2.3",
3
+ "manifestSha256": "11964875450f3526da487cc8d66618065fb3e0d1a5d69250a27a8def2b514fb5",
4
+ "manifestJson": "{\r\n \"version\": \"v0.2.3\",\r\n \"sourceRevision\": \"cfcabd40e52e86bb30461888fe101f37b4996774\",\r\n \"packagingRevision\": \"cfcabd40e52e86bb30461888fe101f37b4996774\",\r\n \"ciRun\": \"https://github.com/mimic-browser/runtime/actions/runs/37752215291\",\r\n \"binariesReusedUnchanged\": true,\r\n \"license\": \"Prosperity-3.0.0\",\r\n \"exampleLicense\": \"MIT\",\r\n \"requirements\": {\r\n \"windows-amd64\": \"Windows x64; validated on Windows 11\",\r\n \"linux-amd64\": \"glibc 2.39+, libgcc_s, installed fonts; Ubuntu 24.04 / WSL2\"\r\n },\r\n \"clients\": {\r\n \"playwright-core\": \"1.63.0\",\r\n \"puppeteer-core\": \"25.10.0\"\r\n },\r\n \"artifacts\": [\r\n {\r\n \"platform\": \"windows-amd64\",\r\n \"archive\": \"mimic-v0.2.3-windows-amd64.zip\",\r\n \"sha256\": \"6040ed4fa90f7cb42df6cb54d9a1481c0945a3cbde340ffebd5d45883ee49a19\",\r\n \"size\": 47677460,\r\n \"binarySha256\": \"a657174860a4e570ef8e0ee8737a566134f4c1c15d595e8f6f823437c1556cb5\",\r\n \"binaryVersion\": \"v0.2.3\",\r\n \"checks\": [\r\n \"runtimecheck:v8\",\r\n \"runtimecheck:quickjs\",\r\n \"runtimecheck:goja\",\r\n \"examples:puppeteer\",\r\n \"examples:playwright\",\r\n \"examples:concurrency\"\r\n ]\r\n },\r\n {\r\n \"platform\": \"linux-amd64\",\r\n \"archive\": \"mimic-v0.2.3-linux-amd64.tar.gz\",\r\n \"sha256\": \"4fb6a71f7b2c5d5ad49136c9456e6f2cc1e70701e91259ff905ae72218c56a9a\",\r\n \"size\": 51026543,\r\n \"binarySha256\": \"414fc217b24301861c078e0e39dafb79110996ea4c4c23ff7d33fab41175eb32\",\r\n \"binaryVersion\": \"v0.2.3\",\r\n \"checks\": [\r\n \"runtimecheck:v8\",\r\n \"runtimecheck:quickjs\",\r\n \"runtimecheck:goja\",\r\n \"examples:puppeteer\",\r\n \"examples:playwright\",\r\n \"examples:concurrency\"\r\n ]\r\n }\r\n ]\r\n}\r\n",
5
+ "manifest": {
6
+ "version": "v0.2.3",
7
+ "sourceRevision": "cfcabd40e52e86bb30461888fe101f37b4996774",
8
+ "packagingRevision": "cfcabd40e52e86bb30461888fe101f37b4996774",
9
+ "ciRun": "https://github.com/mimic-browser/runtime/actions/runs/37752215291",
10
+ "binariesReusedUnchanged": true,
11
+ "license": "Prosperity-3.0.0",
12
+ "exampleLicense": "MIT",
13
+ "requirements": {
14
+ "windows-amd64": "Windows x64; validated on Windows 11",
15
+ "linux-amd64": "glibc 2.39+, libgcc_s, installed fonts; Ubuntu 24.04 / WSL2"
16
+ },
17
+ "clients": {
18
+ "playwright-core": "1.63.0",
19
+ "puppeteer-core": "25.10.0"
20
+ },
21
+ "artifacts": [
22
+ {
23
+ "platform": "windows-amd64",
24
+ "archive": "mimic-v0.2.3-windows-amd64.zip",
25
+ "sha256": "6040ed4fa90f7cb42df6cb54d9a1481c0945a3cbde340ffebd5d45883ee49a19",
26
+ "size": 47677460,
27
+ "binarySha256": "a657174860a4e570ef8e0ee8737a566134f4c1c15d595e8f6f823437c1556cb5",
28
+ "binaryVersion": "v0.2.3",
29
+ "checks": [
30
+ "runtimecheck:v8",
31
+ "runtimecheck:quickjs",
32
+ "runtimecheck:goja",
33
+ "examples:puppeteer",
34
+ "examples:playwright",
35
+ "examples:concurrency"
36
+ ]
37
+ },
38
+ {
39
+ "platform": "linux-amd64",
40
+ "archive": "mimic-v0.2.3-linux-amd64.tar.gz",
41
+ "sha256": "4fb6a71f7b2c5d5ad49136c9456e6f2cc1e70701e91259ff905ae72218c56a9a",
42
+ "size": 51026543,
43
+ "binarySha256": "414fc217b24301861c078e0e39dafb79110996ea4c4c23ff7d33fab41175eb32",
44
+ "binaryVersion": "v0.2.3",
45
+ "checks": [
46
+ "runtimecheck:v8",
47
+ "runtimecheck:quickjs",
48
+ "runtimecheck:goja",
49
+ "examples:puppeteer",
50
+ "examples:playwright",
51
+ "examples:concurrency"
52
+ ]
53
+ }
54
+ ]
55
+ },
56
+ "baseUrl": "https://github.com/mimic-browser/runtime/releases/download/v0.2.3"
57
+ }
@@ -0,0 +1,413 @@
1
+ require 'digest'
2
+ require 'fileutils'
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'open3'
6
+ require 'rubygems/package'
7
+ require 'securerandom'
8
+ require 'socket'
9
+ require 'time'
10
+ require 'zlib'
11
+ require 'zip'
12
+
13
+ module MimicSDK
14
+ class RuntimeError < StandardError; end
15
+
16
+ class RuntimeManager
17
+ attr_reader :options
18
+ VERSION_PATTERN = /\Av?\d+\.\d+\.\d+(?:-beta\.\d+)?\z/
19
+ SHA_PATTERN = /\A[0-9a-f]{64}\z/
20
+
21
+ def initialize(**options)
22
+ @options = options
23
+ end
24
+
25
+ def normalize_version(value)
26
+ raise RuntimeError, "Expected an exact runtime version: #{value.inspect}" unless value.is_a?(String) && VERSION_PATTERN.match?(value)
27
+ 'v' + value.delete_prefix('v')
28
+ end
29
+
30
+ def root
31
+ File.expand_path(options[:runtime_dir] || ENV['MIMIC_RUNTIME_DIR'] ||
32
+ if Gem.win_platform?
33
+ File.join(ENV.fetch('LOCALAPPDATA'), 'Mimic', 'runtimes')
34
+ else
35
+ File.join(ENV['XDG_CACHE_HOME'] || File.join(Dir.home, '.cache'), 'Mimic', 'runtimes')
36
+ end)
37
+ end
38
+
39
+ def platform
40
+ cpu = RbConfig::CONFIG['host_cpu']
41
+ raise RuntimeError, "Unsupported runtime CPU: #{cpu}" unless %w[x86_64 amd64].include?(cpu)
42
+ return 'windows-amd64' if Gem.win_platform?
43
+ raise RuntimeError, 'Only Windows x64 and Linux glibc x64 are packaged' unless RUBY_PLATFORM.include?('linux')
44
+ version, status = Open3.capture2('getconf', 'GNU_LIBC_VERSION')
45
+ match = version.match(/\Aglibc (\d+)\.(\d+)/)
46
+ raise RuntimeError, 'Linux runtime requires glibc 2.39+' unless status.success? && match && ([match[1].to_i, match[2].to_i] <=> [2, 39]) >= 0
47
+ 'linux-amd64'
48
+ end
49
+
50
+ def downloads?
51
+ options.fetch(:allow_download) { ENV['MIMIC_DOWNLOAD'] != '0' }
52
+ end
53
+
54
+ def validate_lock(lock)
55
+ version = normalize_version(lock.fetch('release'))
56
+ raise RuntimeError, 'Lock release must be normalized' unless version == lock['release']
57
+ bytes = lock.fetch('manifestJson')
58
+ raise RuntimeError, 'Manifest integrity mismatch' unless SHA_PATTERN.match?(lock.fetch('manifestSha256')) && Digest::SHA256.hexdigest(bytes) == lock['manifestSha256']
59
+ manifest = JSON.parse(bytes)
60
+ raise RuntimeError, 'Manifest bytes and parsed identity differ' unless manifest == lock.fetch('manifest')
61
+ raise RuntimeError, 'Manifest release/source mismatch' unless manifest['version'] == version && manifest['sourceRevision'].is_a?(String) && manifest['sourceRevision'].match?(/\A[0-9a-f]{40}\z/)
62
+ raise RuntimeError, 'Lock must use the exact official release URL' unless lock['baseUrl'] == "https://github.com/mimic-browser/runtime/releases/download/#{version}"
63
+ platforms = []
64
+ manifest.fetch('artifacts').each do |artifact|
65
+ host = artifact.fetch('platform')
66
+ raise RuntimeError, 'Invalid manifest platform' unless %w[windows-amd64 linux-amd64].include?(host) && !platforms.include?(host)
67
+ platforms << host
68
+ suffix = host.start_with?('windows') ? '.zip' : '.tar.gz'
69
+ valid = artifact['archive'] == "mimic-#{version}-#{host}#{suffix}" && artifact['binaryVersion'] == version &&
70
+ SHA_PATTERN.match?(artifact.fetch('sha256')) && SHA_PATTERN.match?(artifact.fetch('binarySha256')) &&
71
+ artifact['size'].is_a?(Integer) && artifact['size'].positive? && artifact['size'] <= 1024**3
72
+ raise RuntimeError, 'Invalid artifact identity' unless valid
73
+ end
74
+ raise RuntimeError, 'Empty artifact manifest' if platforms.empty?
75
+ lock
76
+ rescue KeyError, JSON::ParserError, TypeError => error
77
+ raise RuntimeError, "Invalid runtime lock: #{error.message}"
78
+ end
79
+
80
+ def resolve_lock
81
+ explicit = options[:lock_file] && validate_lock(JSON.parse(File.binread(options[:lock_file])))
82
+ version = options[:runtime_version] && normalize_version(options[:runtime_version])
83
+ raise RuntimeError, 'Explicit version and lock conflict' if explicit && version && explicit['release'] != version
84
+ return explicit if explicit
85
+ version ||= normalize_version(ENV['MIMIC_RUNTIME_VERSION']) if ENV['MIMIC_RUNTIME_VERSION']
86
+ default = validate_lock(JSON.parse(File.binread(File.join(__dir__, 'runtime-lock.json'))))
87
+ return default if !version || version == default['release']
88
+ cached = File.join(root, '.manifests', "#{version}.json")
89
+ if File.file?(cached)
90
+ lock = validate_lock(JSON.parse(File.binread(cached)))
91
+ raise RuntimeError, 'Cached release mismatch' unless lock['release'] == version
92
+ return lock
93
+ end
94
+ base = "https://github.com/mimic-browser/runtime/releases/download/#{version}"
95
+ bytes = download("#{base}/release-manifest.json", limit: 4 * 1024**2)
96
+ sums = download("#{base}/SHA256SUMS", limit: 1024**2)
97
+ sha = Digest::SHA256.hexdigest(bytes)
98
+ raise RuntimeError, 'Manifest checksum list mismatch' unless sums.lines.any? { |line| line.strip == "#{sha} release-manifest.json" }
99
+ lock = validate_lock({ 'release' => version, 'manifestJson' => bytes, 'manifest' => JSON.parse(bytes), 'manifestSha256' => sha, 'baseUrl' => base })
100
+ FileUtils.mkdir_p(File.dirname(cached))
101
+ publish_manifest(cached, lock)
102
+ lock
103
+ end
104
+
105
+ def download(url, limit:, redirects: 5)
106
+ raise RuntimeError, 'Downloads disabled; preinstall the exact runtime or supply its verified archive' unless downloads?
107
+ uri = URI(url)
108
+ raise RuntimeError, 'Runtime downloads require HTTPS' unless uri.scheme == 'https'
109
+ proxy = uri.find_proxy
110
+ http = Net::HTTP.new(uri.host, uri.port, proxy&.host, proxy&.port,
111
+ proxy&.user && URI.decode_www_form_component(proxy.user),
112
+ proxy&.password && URI.decode_www_form_component(proxy.password))
113
+ http.use_ssl = true
114
+ http.open_timeout = 20
115
+ http.read_timeout = 120
116
+ body = String.new(encoding: Encoding::BINARY)
117
+ response = http.start do |connection|
118
+ connection.request_get(uri.request_uri) do |result|
119
+ if result.is_a?(Net::HTTPSuccess)
120
+ result.read_body do |chunk|
121
+ check_cancelled
122
+ raise RuntimeError, 'Download exceeds declared size' if body.bytesize + chunk.bytesize > limit
123
+ body << chunk
124
+ end
125
+ end
126
+ result
127
+ end
128
+ end
129
+ if response.is_a?(Net::HTTPRedirection)
130
+ raise RuntimeError, 'Too many download redirects' unless redirects.positive?
131
+ return download(URI.join(url, response.fetch('location')).to_s, limit: limit, redirects: redirects - 1)
132
+ end
133
+ raise RuntimeError, "Runtime download HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
134
+ body
135
+ end
136
+
137
+ def with_install_lock(release, host)
138
+ parent = File.join(root, '.locks')
139
+ FileUtils.mkdir_p(parent)
140
+ directory = File.join(parent, "#{release}-#{host}.lock")
141
+ deadline = monotonic + options.fetch(:lock_timeout, 120)
142
+ loop do
143
+ check_cancelled
144
+ begin
145
+ Dir.mkdir(directory)
146
+ break
147
+ rescue Errno::EEXIST
148
+ raise RuntimeError, "Installation lock timed out: #{directory}; inspect owner before stale-lock repair" if monotonic >= deadline
149
+ sleep 0.05
150
+ end
151
+ end
152
+ begin
153
+ owner_token = SecureRandom.uuid
154
+ atomic_json(File.join(directory, 'owner.json'), { 'pid' => Process.pid, 'hostname' => Socket.gethostname, 'token' => owner_token, 'createdAt' => Time.now.utc.iso8601 })
155
+ yield
156
+ ensure
157
+ owner_path = File.join(directory, 'owner.json')
158
+ if File.file?(owner_path) && JSON.parse(File.binread(owner_path))['token'] == owner_token
159
+ File.delete(owner_path)
160
+ Dir.rmdir(directory)
161
+ end
162
+ end
163
+ end
164
+
165
+ def install
166
+ check_cancelled
167
+ host = platform
168
+ lock = resolve_lock
169
+ artifact = lock['manifest']['artifacts'].find { |item| item['platform'] == host }
170
+ raise RuntimeError, "No artifact for #{host}" unless artifact
171
+ name = host.start_with?('windows') ? 'mimic.exe' : 'mimic'
172
+ destination = File.join(root, lock['release'], host, artifact['binarySha256'])
173
+ receipt = { 'release' => lock['release'], 'platform' => host, 'sourceRevision' => lock['manifest']['sourceRevision'],
174
+ 'archiveSha256' => artifact['sha256'], 'binarySha256' => artifact['binarySha256'],
175
+ 'manifestSha256' => lock['manifestSha256'], 'executable' => name }
176
+ with_install_lock(lock['release'], host) do
177
+ if File.exist?(destination)
178
+ verify(destination, receipt)
179
+ return receipt.merge('path' => File.join(destination, name))
180
+ end
181
+ archive = options[:archive_path] ? File.binread(options[:archive_path]) : download("#{lock['baseUrl']}/#{artifact['archive']}", limit: artifact['size'])
182
+ raise RuntimeError, 'Archive integrity mismatch' unless archive.bytesize == artifact['size'] && Digest::SHA256.hexdigest(archive) == artifact['sha256']
183
+ staging = File.join(root, '.staging', SecureRandom.uuid)
184
+ FileUtils.mkdir_p(staging)
185
+ begin
186
+ extract(archive, artifact['archive'], staging, "mimic-#{lock['release']}-#{host}")
187
+ binary = File.join(staging, name)
188
+ raise RuntimeError, 'Executable integrity mismatch' unless File.file?(binary) && Digest::SHA256.file(binary).hexdigest == artifact['binarySha256']
189
+ File.chmod(0o700, binary)
190
+ atomic_json(File.join(staging, 'installation.json'), receipt)
191
+ FileUtils.mkdir_p(File.dirname(destination))
192
+ File.rename(staging, destination)
193
+ FileUtils.mkdir_p(File.join(root, '.manifests'))
194
+ publish_manifest(File.join(root, '.manifests', "#{lock['release']}.json"), lock)
195
+ ensure
196
+ FileUtils.rm_rf(staging)
197
+ end
198
+ end
199
+ receipt.merge('path' => File.join(destination, name))
200
+ end
201
+
202
+ def verify(directory, expected = nil)
203
+ receipt = JSON.parse(File.binread(File.join(directory, 'installation.json')))
204
+ expected&.each { |key, value| raise RuntimeError, 'Cached provenance mismatch' unless receipt[key] == value }
205
+ executable = receipt.fetch('executable')
206
+ raise RuntimeError, 'Invalid executable receipt path' unless %w[mimic mimic.exe].include?(executable)
207
+ binary = File.join(directory, executable)
208
+ raise RuntimeError, 'Cached executable is not a regular file' unless File.file?(binary) && !File.symlink?(binary)
209
+ raise RuntimeError, 'Cached executable hash mismatch' unless Digest::SHA256.file(binary).hexdigest == receipt.fetch('binarySha256')
210
+ receipt.merge('path' => binary)
211
+ rescue Errno::ENOENT, JSON::ParserError, KeyError => error
212
+ raise RuntimeError, "Incomplete cached installation: #{error.message}"
213
+ end
214
+
215
+ def list
216
+ Dir.glob(File.join(root, 'v*', '*-amd64', '*', 'installation.json')).map { |file| verify(File.dirname(file)) }
217
+ end
218
+
219
+ def prune(installation)
220
+ directory = File.dirname(File.expand_path(installation.fetch('path')))
221
+ expected = File.join(root, installation.fetch('release'), installation.fetch('platform'), installation.fetch('binarySha256'))
222
+ raise RuntimeError, 'Prune target escapes the installation root' unless directory == expected
223
+ resolved_root = File.realpath(root)
224
+ resolved_directory = File.realpath(directory)
225
+ raise RuntimeError, 'Prune target traverses a symbolic link' unless resolved_directory == File.join(resolved_root, installation['release'], installation['platform'], installation['binarySha256'])
226
+ with_install_lock(installation['release'], installation['platform']) do
227
+ verify(directory, installation.reject { |key, _| key == 'path' })
228
+ Dir.glob(File.join(directory, '.leases', '*.json')).each do |file|
229
+ lease = JSON.parse(File.binread(file))
230
+ raise RuntimeError, 'Foreign or incomplete runtime lease' unless lease['hostname'] == Socket.gethostname && lease['runtimePid'].is_a?(Integer) && lease['runtimePid'].positive?
231
+ begin
232
+ Process.kill(0, lease['runtimePid'])
233
+ raise RuntimeError, 'Cannot prune a running runtime'
234
+ rescue Errno::ESRCH
235
+ # Confirmed dead local process; this retained lease is stale.
236
+ end
237
+ end
238
+ FileUtils.rm_rf(directory)
239
+ end
240
+ nil
241
+ end
242
+
243
+ def launch
244
+ check_cancelled
245
+ platform
246
+ explicit = options[:executable_path] || ENV['MIMIC_EXECUTABLE_PATH']
247
+ if explicit
248
+ path = File.expand_path(explicit)
249
+ raise RuntimeError, "Invalid explicit executable: #{path}" unless File.file?(path) && File.executable?(path)
250
+ lock = options[:lock_file] && resolve_lock
251
+ if lock
252
+ artifact = lock['manifest']['artifacts'].find { |item| item['platform'] == platform }
253
+ raise RuntimeError, 'Explicit executable does not match lock' unless artifact && Digest::SHA256.file(path).hexdigest == artifact['binarySha256']
254
+ end
255
+ expected = lock ? lock['release'] : options[:runtime_version] || ENV['MIMIC_RUNTIME_VERSION']
256
+ return RuntimeProcess.new(path, expected_version: expected && normalize_version(expected), timeout: options.fetch(:startup_timeout, 30), cancelled: options[:cancelled])
257
+ end
258
+ installation = install
259
+ with_install_lock(installation['release'], installation['platform']) do
260
+ verify(File.dirname(installation['path']), installation.reject { |key, _| key == 'path' })
261
+ RuntimeProcess.new(installation['path'], expected_version: installation['release'], timeout: options.fetch(:startup_timeout, 30), cancelled: options[:cancelled], lease_directory: File.join(File.dirname(installation['path']), '.leases'))
262
+ end
263
+ end
264
+
265
+ private
266
+
267
+ def check_cancelled
268
+ raise RuntimeError, 'Runtime operation cancelled' if options[:cancelled]&.call
269
+ end
270
+
271
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
272
+ def publish_manifest(path, value)
273
+ temporary = path + '.' + SecureRandom.uuid + '.tmp'
274
+ File.binwrite(temporary, JSON.generate(value))
275
+ begin
276
+ File.link(temporary, path)
277
+ rescue Errno::EEXIST
278
+ existing = validate_lock(JSON.parse(File.binread(path)))
279
+ raise RuntimeError, 'Conflicting immutable release manifest' unless existing['release'] == value['release'] && existing['manifestSha256'] == value['manifestSha256']
280
+ end
281
+ ensure
282
+ File.delete(temporary) if temporary && File.exist?(temporary)
283
+ end
284
+
285
+ def atomic_json(path, value)
286
+ temporary = path + '.' + SecureRandom.uuid + '.tmp'
287
+ File.binwrite(temporary, JSON.generate(value))
288
+ File.rename(temporary, path)
289
+ ensure
290
+ File.delete(temporary) if temporary && File.exist?(temporary)
291
+ end
292
+
293
+ def extract(bytes, name, directory, prefix)
294
+ seen = {}
295
+ total = 0
296
+ write = lambda do |entry_name, size, type, data|
297
+ raise RuntimeError, 'Unsafe archive path' if entry_name.start_with?('/') || entry_name.include?('\\') || entry_name.include?(':') || entry_name.split('/').any? { |part| %w[. ..].include?(part) }
298
+ next if entry_name == prefix + '/' && type == :directory
299
+ raise RuntimeError, 'Unexpected archive root' unless entry_name.start_with?(prefix + '/')
300
+ relative = entry_name.delete_prefix(prefix + '/').delete_suffix('/')
301
+ raise RuntimeError, 'Duplicate archive entry' if relative.empty? || seen[relative]
302
+ seen[relative] = true
303
+ output = File.join(directory, relative)
304
+ if type == :directory
305
+ FileUtils.mkdir_p(output)
306
+ next
307
+ end
308
+ raise RuntimeError, 'Archive links/special files are unsupported' unless type == :file
309
+ total += size
310
+ raise RuntimeError, 'Archive extraction size exceeds bound' if size.negative? || total > 2 * 1024**3
311
+ FileUtils.mkdir_p(File.dirname(output))
312
+ File.open(output, 'wx', 0o600) do |file|
313
+ count = IO.copy_stream(data, file, size)
314
+ raise RuntimeError, 'Archive entry length mismatch' unless count == size
315
+ end
316
+ end
317
+ if name.end_with?('.zip')
318
+ Zip::InputStream.open(StringIO.new(bytes)) do |zip|
319
+ while (entry = zip.get_next_entry)
320
+ type = entry.symlink? ? :link : entry.directory? ? :directory : :file
321
+ write.call(entry.name, entry.size, type, zip)
322
+ end
323
+ end
324
+ else
325
+ Zlib::GzipReader.wrap(StringIO.new(bytes)) do |gzip|
326
+ Gem::Package::TarReader.new(gzip) do |tar|
327
+ tar.each { |entry| write.call(entry.full_name, entry.header.size, entry.directory? ? :directory : entry.file? ? :file : :link, entry) }
328
+ end
329
+ end
330
+ end
331
+ end
332
+ end
333
+
334
+ class RuntimeProcess
335
+ attr_reader :endpoint, :transport, :mimic, :pid
336
+ def initialize(executable, expected_version: nil, timeout: 30, lease_directory: nil, cancelled: nil)
337
+ @mutex, @close_mutex, @ready = Mutex.new, Mutex.new, ConditionVariable.new
338
+ @log = ''
339
+ stdin, stdout, stderr, @waiter = Open3.popen3(executable, '--browser-mode', 'headless', '--listen', '127.0.0.1:0')
340
+ stdin.close
341
+ @pid = @waiter.pid
342
+ @readers = [stdout, stderr].map do |stream|
343
+ Thread.new do
344
+ stream.each_line do |line|
345
+ @mutex.synchronize do
346
+ @log = (@log + line).byteslice(-16_384, 16_384) || (@log + line)
347
+ if stream == stdout && (match = line.match(%r{\AMimic listening on (http://127\.0\.0\.1:(\d+))\s*\z}))
348
+ @endpoint = match[1] if (1..65_535).cover?(match[2].to_i)
349
+ @ready.broadcast
350
+ end
351
+ end
352
+ end
353
+ ensure
354
+ stream.close
355
+ end
356
+ end
357
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
358
+ @mutex.synchronize do
359
+ until @endpoint
360
+ raise RuntimeError, 'Runtime startup cancelled' if cancelled&.call
361
+ raise RuntimeError, "Runtime exited: #{@log}" unless @waiter.alive?
362
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
363
+ raise RuntimeError, "Runtime startup timed out: #{@log}" unless remaining.positive?
364
+ @ready.wait(@mutex, [remaining, 0.05].min)
365
+ end
366
+ end
367
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
368
+ raise RuntimeError, 'Runtime startup timed out before discovery' unless remaining.positive?
369
+ @transport = Transport.new(@endpoint, timeout: remaining)
370
+ @mimic = Client.new(@transport)
371
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
372
+ raise RuntimeError, 'Runtime startup timed out before identity' unless remaining.positive?
373
+ identity = @transport.call_raw('Mimic.getVersion', {}, timeout: remaining, cancelled: cancelled)
374
+ raise RuntimeError, 'Invalid Mimic runtime identity' if identity['version'].to_s.empty? || identity['chromeVersion'].to_s.empty?
375
+ raise RuntimeError, "Requested #{expected_version}, runtime reports #{identity['version']}" if expected_version && ![expected_version, expected_version.delete_prefix('v')].include?(identity['version'])
376
+ if lease_directory
377
+ FileUtils.mkdir_p(lease_directory)
378
+ @lease = File.join(lease_directory, SecureRandom.uuid + '.json')
379
+ File.binwrite(@lease, JSON.generate({ 'launcherPid' => Process.pid, 'runtimePid' => @pid, 'hostname' => Socket.gethostname, 'createdAt' => Time.now.utc.iso8601 }))
380
+ end
381
+ rescue StandardError
382
+ close
383
+ raise
384
+ end
385
+
386
+ def close
387
+ @close_mutex.synchronize do
388
+ return if @closed
389
+ @closed = true
390
+ begin
391
+ @transport&.call_raw('Browser.close', {}, timeout: 2)
392
+ rescue StandardError
393
+ # Disconnect during graceful shutdown is expected; the child wait below
394
+ # remains authoritative and force-stop is bounded to this owned PID.
395
+ ensure
396
+ @transport&.close
397
+ end
398
+ if @waiter&.alive?
399
+ unless @waiter.join(2)
400
+ Process.kill('TERM', @pid) rescue Errno::ESRCH
401
+ unless @waiter.join(2)
402
+ Process.kill('KILL', @pid) rescue Errno::ESRCH
403
+ @waiter.join
404
+ end
405
+ end
406
+ end
407
+ @readers&.each { |reader| reader.join(1) }
408
+ File.delete(@lease) if @lease && File.exist?(@lease)
409
+ end
410
+ nil
411
+ end
412
+ end
413
+ end