microsandbox-rb 0.17.0 → 0.17.2

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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +229 -8
  3. data/Cargo.lock +45 -46
  4. data/DESIGN.md +90 -72
  5. data/README.md +178 -669
  6. data/SECURITY.md +65 -0
  7. data/docs/configuration.md +154 -0
  8. data/docs/errors.md +74 -0
  9. data/docs/execution.md +108 -0
  10. data/docs/filesystem.md +45 -0
  11. data/docs/images.md +65 -0
  12. data/docs/lifecycle.md +141 -0
  13. data/docs/observability.md +99 -0
  14. data/docs/releasing.md +117 -0
  15. data/docs/runtime.md +154 -0
  16. data/docs/surface.md +75 -0
  17. data/ext/microsandbox/Cargo.toml +9 -5
  18. data/ext/microsandbox/src/agent.rs +13 -11
  19. data/ext/microsandbox/src/backend.rs +1 -1
  20. data/ext/microsandbox/src/exec.rs +12 -8
  21. data/ext/microsandbox/src/fs_stream.rs +19 -7
  22. data/ext/microsandbox/src/host_path.rs +108 -0
  23. data/ext/microsandbox/src/image.rs +2 -2
  24. data/ext/microsandbox/src/lib.rs +3 -2
  25. data/ext/microsandbox/src/runtime.rs +189 -13
  26. data/ext/microsandbox/src/sandbox.rs +149 -102
  27. data/ext/microsandbox/src/snapshot.rs +17 -11
  28. data/ext/microsandbox/src/ssh.rs +22 -16
  29. data/ext/microsandbox/src/stream.rs +6 -2
  30. data/ext/microsandbox/src/volume.rs +50 -17
  31. data/lib/microsandbox/fs.rb +4 -8
  32. data/lib/microsandbox/image.rb +2 -2
  33. data/lib/microsandbox/log_entry.rb +1 -1
  34. data/lib/microsandbox/metrics.rb +1 -1
  35. data/lib/microsandbox/outbound_proxy.rb +8 -0
  36. data/lib/microsandbox/sandbox.rb +128 -13
  37. data/lib/microsandbox/snapshot.rb +1 -1
  38. data/lib/microsandbox/ssh.rb +2 -10
  39. data/lib/microsandbox/streams.rb +8 -5
  40. data/lib/microsandbox/version.rb +1 -1
  41. data/lib/microsandbox/volume.rb +31 -14
  42. data/lib/microsandbox-rb.rb +7 -0
  43. data/lib/microsandbox.rb +88 -31
  44. data/sig/microsandbox.rbs +24 -0
  45. metadata +14 -1
@@ -42,13 +42,13 @@ module Microsandbox
42
42
  # @return [Time, nil]
43
43
  def created_at
44
44
  ms = @native.created_at_ms
45
- ms && Time.at(ms / 1000.0)
45
+ Microsandbox.time_from_ms(ms)
46
46
  end
47
47
 
48
48
  # @return [Time, nil]
49
49
  def updated_at
50
50
  ms = @native.updated_at_ms
51
- ms && Time.at(ms / 1000.0)
51
+ Microsandbox.time_from_ms(ms)
52
52
  end
53
53
 
54
54
  # Gracefully stop the sandbox (SIGTERM→SIGKILL escalation, 10s default).
@@ -263,7 +263,7 @@ module Microsandbox
263
263
 
264
264
  # @return [Time] when the stopped state was observed
265
265
  def observed_at
266
- Time.at(@observed_at_ms / 1000.0)
266
+ Microsandbox.time_from_ms(@observed_at_ms)
267
267
  end
268
268
 
269
269
  def inspect
@@ -495,7 +495,11 @@ module Microsandbox
495
495
  # @return [Sandbox, Object] the sandbox, or the block's return value
496
496
  def create(name, **kwargs, &block)
497
497
  opts = build_create_opts(**kwargs)
498
- sandbox = new(Native::Sandbox.create(name.to_s, opts))
498
+ Microsandbox.ensure_runtime!
499
+ # Everything Ruby-level happens BEFORE `acquire`: its interrupt mask must
500
+ # cover only the native call plus the wrap, never caller-supplied code.
501
+ sandbox_name = name.to_s
502
+ sandbox = acquire { Native::Sandbox.create(sandbox_name, opts) }
499
503
  return sandbox unless block_given?
500
504
 
501
505
  begin
@@ -551,7 +555,9 @@ module Microsandbox
551
555
  # @return [Sandbox, Object] the live sandbox, or the block's return value
552
556
  def connect_or_create(name, **kwargs, &block)
553
557
  opts = build_create_opts(**kwargs)
554
- sandbox = new(Native::Sandbox.connect_or_create(name.to_s, opts))
558
+ Microsandbox.ensure_runtime!
559
+ sandbox_name = name.to_s # outside the interrupt mask, like in {create}
560
+ sandbox = acquire { Native::Sandbox.connect_or_create(sandbox_name, opts) }
555
561
  return sandbox unless block_given?
556
562
 
557
563
  begin
@@ -582,11 +588,74 @@ module Microsandbox
582
588
  "for progress, then call #sandbox and stop it when done"
583
589
  end
584
590
  opts = build_create_opts(**kwargs)
591
+ Microsandbox.ensure_runtime!
585
592
  PullSession.new(Native::Sandbox.create_with_progress(name.to_s, opts))
586
593
  end
587
594
 
588
595
  # @api private
589
- # Shared keyword-option builder for {create}/{create_with_progress}.
596
+ # Wrap the native sandbox the block acquires, and stop it again if an
597
+ # interrupt cuts in before the caller has it.
598
+ #
599
+ # A native call cannot be interrupted while it runs, so a `Thread#raise`
600
+ # (what `Timeout.timeout` uses) or a `Thread#kill` that arrives during the
601
+ # block is delivered after it — at the next interrupt checkpoint, which is
602
+ # still in here, before the caller ever sees the object. Left alone the
603
+ # sandbox would be garbage and only GC would stop it, "eventually"; for
604
+ # `Timeout.timeout { Sandbox.create(...) }` that is not good enough. So:
605
+ #
606
+ # - the acquisition runs under `Thread.handle_interrupt(Object => :never)`,
607
+ # which holds a queued exception (or kill) back until the wrapper is
608
+ # assigned. The mask covers only the uninterruptible native call plus
609
+ # the wrap, so it changes no observable timing; runtime provisioning
610
+ # ({Microsandbox.ensure_runtime!}) and the block forms' `yield` stay
611
+ # outside it, with normal interrupt handling. For the same reason the
612
+ # block passed here must contain NOTHING but the native call: argument
613
+ # conversion (`name.to_s`, option Hashes) is the caller's own code and
614
+ # has to run before `acquire`, where it stays interruptible;
615
+ # - the `ensure` plus the `acquired` flag — not `rescue Exception; raise` —
616
+ # is what also covers `Thread#kill`, which no `rescue` can catch.
617
+ #
618
+ # A SIGINT (Ctrl-C on the main thread) is the exception: it is not queued
619
+ # like those two and cannot be masked. One that lands during the native
620
+ # call is raised the moment the call returns — the VM checks interrupts
621
+ # as it pops the native method's frame, before any Ruby code here can
622
+ # hold the result — so no assignment order would catch it; the sandbox
623
+ # is then torn down when the unreferenced object is garbage-collected.
624
+ #
625
+ # Ownership: {connect_or_create} can hand back a sandbox that was already
626
+ # running for someone else, and {start} restarts a persisted one. The
627
+ # cleanup therefore applies EXACTLY the predicate the block form of
628
+ # {connect_or_create} uses — {Sandbox#owns_lifecycle?} — in all three
629
+ # callers, {create} included: a sandbox this call does not own is never
630
+ # stopped here. Its reference is just let go (GC closes the handle, the
631
+ # sandbox keeps running).
632
+ # @yieldreturn [Native::Sandbox]
633
+ # @return [Sandbox]
634
+ def acquire
635
+ sandbox = nil
636
+ acquired = false
637
+ begin
638
+ Thread.handle_interrupt(Object => :never) do
639
+ sandbox = new(yield)
640
+ end
641
+ acquired = true
642
+ ensure
643
+ if sandbox && !acquired && sandbox.owns_lifecycle?
644
+ begin
645
+ sandbox.stop
646
+ rescue Microsandbox::Error
647
+ # best-effort cleanup; the interrupt is what the caller must see
648
+ end
649
+ end
650
+ end
651
+ sandbox
652
+ end
653
+
654
+ # @api private
655
+ # Shared keyword-option builder for {create}/{connect_or_create}/
656
+ # {create_with_progress}. Pure validation and normalization: the callers
657
+ # provision the runtime ({Microsandbox.ensure_runtime!}) only after this
658
+ # returns, so a rejected option never triggers a runtime download.
590
659
  def build_create_opts(image: nil, cpus: nil, max_cpus: nil, memory: nil, max_memory: nil,
591
660
  env: nil, workdir: nil,
592
661
  shell: nil, user: nil, hostname: nil, labels: nil, scripts: nil,
@@ -610,7 +679,14 @@ module Microsandbox
610
679
  if root_disk && oci_upper_size
611
680
  raise ArgumentError, "pass either root_disk: or oci_upper_size:, not both"
612
681
  end
613
- Microsandbox.ensure_runtime!
682
+ # The core refuses patches on a snapshot-booted sandbox, but only after
683
+ # it has claimed the sandbox name on disk. Refuse the pair before any
684
+ # side effect.
685
+ if from_snapshot && patches && !Array(patches).empty?
686
+ raise ArgumentError,
687
+ "patches: cannot be combined with from_snapshot: (a snapshot restores its own " \
688
+ "root disk; apply changes after boot with sandbox.fs instead)"
689
+ end
614
690
  # `fstype:` names the inner filesystem of a disk-image rootfs, so it only
615
691
  # applies when `image:` is a disk-image path (a local path ending in
616
692
  # .raw/.qcow2/.vmdk). Routing an OCI ref (e.g. "python") through the
@@ -692,7 +768,9 @@ module Microsandbox
692
768
  # @return [Sandbox]
693
769
  def start(name, detached: false)
694
770
  Microsandbox.ensure_runtime!
695
- new(Native::Sandbox.start(name.to_s, {"detached" => detached}))
771
+ sandbox_name = name.to_s # outside the interrupt mask, like in {create}
772
+ opts = {"detached" => detached}
773
+ acquire { Native::Sandbox.start(sandbox_name, opts) }
696
774
  end
697
775
 
698
776
  # Fetch a controllable handle for a sandbox by name (running or not).
@@ -779,7 +857,16 @@ module Microsandbox
779
857
  end
780
858
 
781
859
  def stringify(hash)
782
- hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v.to_s }
860
+ # Anything that converts to pairs is fine (a Hash, ENV, an Array of
861
+ # pairs). For the rest, name the type, not the value: env values are
862
+ # often credentials.
863
+ pairs = begin
864
+ hash.to_h if hash.respond_to?(:to_h)
865
+ rescue TypeError
866
+ nil
867
+ end
868
+ raise ArgumentError, "expected a Hash of names to values (got #{hash.class})" unless pairs
869
+ pairs.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v.to_s }
783
870
  end
784
871
 
785
872
  # Wrap a native page Hash ({"sandboxes" => [...], "next_cursor" => ...})
@@ -844,7 +931,21 @@ module Microsandbox
844
931
  # :inject_basic_auth / :inject_query / :inject_body, and :on_violation (see
845
932
  # {#normalize_violation}).
846
933
  def normalize_secrets(secrets)
847
- Array(secrets).map { |spec| normalize_secret(spec) }
934
+ if secrets.is_a?(Hash)
935
+ # One spec given bare is fine. A Hash keyed by variable name is the
936
+ # shape {Sandbox#modify} takes, where `env:` also means something
937
+ # else (the HOST variable to read); it is not a create-time spec.
938
+ unless secrets.key?(:env) || secrets.key?("env")
939
+ raise ArgumentError,
940
+ "secrets: takes an Array of Hashes like { env:, value:, host: } at create time; " \
941
+ "the { \"NAME\" => { ... } } form belongs to #modify"
942
+ end
943
+ secrets = [secrets]
944
+ end
945
+ Array(secrets).map do |spec|
946
+ raise ArgumentError, "secret spec must be a Hash (got #{spec.class})" unless spec.is_a?(Hash)
947
+ normalize_secret(spec)
948
+ end
848
949
  end
849
950
 
850
951
  # @api private
@@ -1409,9 +1510,11 @@ module Microsandbox
1409
1510
  # plain Hash) into string-keyed Hashes for the native layer. Values are
1410
1511
  # passed through unchanged (mode stays Integer, content stays String).
1411
1512
  def normalize_patches(patches)
1513
+ patches = [patches] if patches.is_a?(Hash) # a single patch, not a list of pairs
1412
1514
  Array(patches).map do |p|
1413
1515
  unless p.is_a?(Hash)
1414
- raise ArgumentError, "patch must be a Hash (use Microsandbox::Patch.*): #{p.inspect}"
1516
+ # Name the type, not the value: a patch can carry file content.
1517
+ raise ArgumentError, "patch must be a Hash (use Microsandbox::Patch.*), got #{p.class}"
1415
1518
  end
1416
1519
  p.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
1417
1520
  end
@@ -1957,10 +2060,22 @@ module Microsandbox
1957
2060
 
1958
2061
  # The booted sandbox. Joins the create task (draining any remaining pull
1959
2062
  # progress first), so call it after iterating progress. The returned
1960
- # {Sandbox} is live — stop it when done. Memoized; callable once.
2063
+ # {Sandbox} is live — stop it when done. Memoized: the native result can be
2064
+ # taken only once, so a later call returns the same {Sandbox}, or re-raises
2065
+ # the same error if the create failed.
2066
+ #
2067
+ # Interrupt-safe like {Sandbox.create}: if a `Timeout.timeout` (or another
2068
+ # `Thread#raise`/`Thread#kill`) arrives while this is waiting, the sandbox
2069
+ # the session produced is stopped before the exception propagates rather
2070
+ # than left running with nothing referring to it. The result is consumed
2071
+ # by then, so a later call raises instead of returning a sandbox.
1961
2072
  # @return [Sandbox]
1962
2073
  def sandbox
1963
- @sandbox ||= Sandbox.new(@native.result)
2074
+ raise @failure if @failure
2075
+ @sandbox ||= Sandbox.acquire { @native.result }
2076
+ rescue Microsandbox::Error => e
2077
+ @failure ||= e
2078
+ raise
1964
2079
  end
1965
2080
  end
1966
2081
  end
@@ -92,7 +92,7 @@ module Microsandbox
92
92
 
93
93
  # @return [Time, nil]
94
94
  def created_at
95
- @created_at_ms && Time.at(@created_at_ms / 1000.0)
95
+ Microsandbox.time_from_ms(@created_at_ms)
96
96
  end
97
97
 
98
98
  # Re-open this snapshot's artifact (cheap metadata validation), returning a
@@ -173,11 +173,7 @@ module Microsandbox
173
173
  session = SftpClient.new(@native.sftp)
174
174
  return session unless block_given?
175
175
 
176
- begin
177
- yield session
178
- ensure
179
- session.close
180
- end
176
+ Microsandbox.close_after(session) { yield session }
181
177
  end
182
178
 
183
179
  # Close the SSH client session. Idempotent. Sends the graceful protocol
@@ -245,11 +241,7 @@ module Microsandbox
245
241
  client = SshClient.new(@native.ssh_open_client(opts))
246
242
  return client unless block_given?
247
243
 
248
- begin
249
- yield client
250
- ensure
251
- client.close
252
- end
244
+ Microsandbox.close_after(client) { yield client }
253
245
  end
254
246
 
255
247
  # Prepare a reusable SSH server endpoint for the sandbox.
@@ -2,17 +2,20 @@
2
2
 
3
3
  module Microsandbox
4
4
  # A live stream of {LogEntry}s, returned by {Sandbox#log_stream}. Enumerable:
5
- # iterate to consume entries as they are appended. With `follow: true` the
6
- # iteration blocks for new entries until the sandbox stops; otherwise it ends
7
- # once the historical log is drained.
5
+ # iterate to consume entries as they are appended. Without `follow:` the
6
+ # iteration ends once the historical log is drained. With `follow: true` it
7
+ # keeps waiting for new entries and does NOT end when the sandbox stops: the
8
+ # runtime follows the log file, not the sandbox, and there is no way to
9
+ # cancel the wait.
8
10
  #
9
11
  # @note **Single-pass, forward-only, single-consumer.** `each` drains a
10
12
  # one-shot native channel, so it is *not* rewindable: a second `each` — or
11
13
  # any `Enumerable` combinator after a partial drain (`to_a` twice, `count`
12
14
  # then `each`, `first(n)` then `each`) — silently yields nothing. Iterate
13
15
  # exactly once, from one thread. With `follow: true`, `each` blocks the
14
- # calling thread uninterruptibly until the sandbox stops (see DESIGN.md on
15
- # GVL release).
16
+ # calling thread uninterruptibly and indefinitely (see DESIGN.md on GVL
17
+ # release); a thread blocked there also keeps the process from exiting
18
+ # normally, so follow from a process you are prepared to kill.
16
19
  #
17
20
  # @example
18
21
  # sb.log_stream(follow: true).each { |entry| print entry.text }
@@ -8,7 +8,7 @@ module Microsandbox
8
8
  # Versioning section of the README for the full gem-to-runtime map. Must equal
9
9
  # the native ext's Cargo crate version (`Native.version`), enforced by
10
10
  # spec/unit/version_spec.rb.
11
- VERSION = "0.17.0"
11
+ VERSION = "0.17.2"
12
12
 
13
13
  # The upstream microsandbox runtime release this gem build embeds — the `tag`
14
14
  # pinned on the `microsandbox`/`microsandbox-network` git deps in
@@ -38,7 +38,7 @@ module Microsandbox
38
38
 
39
39
  # @return [Time, nil]
40
40
  def created_at
41
- @created_at_ms && Time.at(@created_at_ms / 1000.0)
41
+ Microsandbox.time_from_ms(@created_at_ms)
42
42
  end
43
43
 
44
44
  # A host-side filesystem view over this volume (read/write its contents
@@ -55,8 +55,10 @@ module Microsandbox
55
55
 
56
56
  # A host-side filesystem view over a named volume, from {Volume.fs} or
57
57
  # {VolumeInfo#fs}. Reads and writes the volume's contents directly on the host,
58
- # without booting a sandbox. All paths are relative to the volume root. Mirrors
59
- # the `VolumeFs` of the official Python/Node SDKs.
58
+ # without booting a sandbox. All paths are relative to the volume root (a
59
+ # leading `/` is the volume root) and may not contain a `..` segment — every
60
+ # operation raises {FilesystemError} for one. Mirrors the `VolumeFs` of the
61
+ # official Python/Node SDKs.
60
62
  class VolumeFs
61
63
  def initialize(native)
62
64
  @native = native
@@ -65,13 +67,13 @@ module Microsandbox
65
67
  # Read a file as raw bytes (ASCII-8BIT).
66
68
  # @return [String]
67
69
  def read(path)
68
- @native.read(path.to_s)
70
+ @native.read(contained(path))
69
71
  end
70
72
 
71
73
  # Read a file as a UTF-8 string.
72
74
  # @return [String]
73
75
  def read_text(path)
74
- @native.read_text(path.to_s)
76
+ @native.read_text(contained(path))
75
77
  end
76
78
 
77
79
  # Write data to a file, creating parent directories as needed.
@@ -80,60 +82,75 @@ module Microsandbox
80
82
  # @return [nil]
81
83
  def write(path, data)
82
84
  bytes = Microsandbox.coerce_write_bytes(data)
83
- @native.write(path.to_s, bytes)
85
+ @native.write(contained(path), bytes)
84
86
  nil
85
87
  end
86
88
 
87
89
  # List the entries of a directory.
88
90
  # @return [Array<FsEntry>]
89
91
  def list(path)
90
- @native.list(path.to_s).map { |entry| FsEntry.new(entry) }
92
+ @native.list(contained(path)).map { |entry| FsEntry.new(entry) }
91
93
  end
92
94
 
93
95
  # Create a directory (and any missing parents).
94
96
  # @return [nil]
95
97
  def mkdir(path)
96
- @native.mkdir(path.to_s)
98
+ @native.mkdir(contained(path))
97
99
  nil
98
100
  end
99
101
 
100
102
  # Remove a single file.
101
103
  # @return [nil]
102
104
  def remove_file(path)
103
- @native.remove_file(path.to_s)
105
+ @native.remove_file(contained(path))
104
106
  nil
105
107
  end
106
108
 
107
109
  # Remove a directory recursively.
108
110
  # @return [nil]
109
111
  def remove_dir(path)
110
- @native.remove_dir(path.to_s)
112
+ @native.remove_dir(contained(path))
111
113
  nil
112
114
  end
113
115
 
114
116
  # @return [Boolean] whether the path exists in the volume
115
117
  def exists?(path)
116
- @native.exists(path.to_s)
118
+ @native.exists(contained(path))
117
119
  end
118
120
 
119
121
  # Copy a file within the volume.
120
122
  # @return [nil]
121
123
  def copy(src, dst)
122
- @native.copy(src.to_s, dst.to_s)
124
+ @native.copy(contained(src), contained(dst))
123
125
  nil
124
126
  end
125
127
 
126
128
  # Rename/move a file or directory within the volume.
127
129
  # @return [nil]
128
130
  def rename(src, dst)
129
- @native.rename(src.to_s, dst.to_s)
131
+ @native.rename(contained(src), contained(dst))
130
132
  nil
131
133
  end
132
134
 
133
135
  # Stat a path.
134
136
  # @return [FsMetadata]
135
137
  def stat(path)
136
- FsMetadata.new(@native.stat(path.to_s))
138
+ FsMetadata.new(@native.stat(contained(path)))
139
+ end
140
+
141
+ private
142
+
143
+ # Volume paths are relative to the volume root and never need a `..`
144
+ # segment, so one is refused here with a clear message instead of being
145
+ # handed to the resolver. The native layer enforces the same rule; this
146
+ # check only exists so the error names the cause.
147
+ def contained(path)
148
+ s = path.to_s
149
+ if s.split("/").include?("..")
150
+ raise Microsandbox::FilesystemError,
151
+ "path traversal outside volume root (`..` segments are not allowed in volume paths)"
152
+ end
153
+ s
137
154
  end
138
155
  end
139
156
 
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Entry point named after the gem. `require "microsandbox"` resolves to
4
+ # whichever installed gem ships that file first, and the separate
5
+ # `microsandbox` gem on RubyGems ships one too; outside Bundler,
6
+ # `require "microsandbox-rb"` always loads this gem.
7
+ require_relative "microsandbox"
data/lib/microsandbox.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "monitor"
4
+
3
5
  require_relative "microsandbox/version"
4
6
 
5
7
  # Load the compiled native extension. Precompiled platform gems stage the
@@ -47,6 +49,13 @@ require_relative "microsandbox/sandbox"
47
49
  # puts sb.exec("python", ["-c", "print('Hello, World!')"]).stdout
48
50
  # end
49
51
  module Microsandbox
52
+ # Serializes runtime provisioning within this process: {install}, {setup} and
53
+ # the first-use check in {ensure_runtime!}. The installer replaces files in
54
+ # place, so two threads racing through a first `Sandbox.create` must not both
55
+ # run it. A Monitor (re-entrant) because ensure_runtime! calls install.
56
+ RUNTIME_SETUP_LOCK = Monitor.new
57
+ private_constant :RUNTIME_SETUP_LOCK
58
+
50
59
  class << self
51
60
  # @return [String] the gem version
52
61
  def version
@@ -74,7 +83,7 @@ module Microsandbox
74
83
  # gem supplies the runtime.
75
84
  # @return [nil]
76
85
  def install
77
- Native.install
86
+ RUNTIME_SETUP_LOCK.synchronize { Native.install }
78
87
  nil
79
88
  end
80
89
 
@@ -93,7 +102,7 @@ module Microsandbox
93
102
  opts["version"] = version.to_s if version
94
103
  opts["force"] = true if force
95
104
  opts["skip_verify"] = true if skip_verify
96
- Native.setup(opts)
105
+ RUNTIME_SETUP_LOCK.synchronize { Native.setup(opts) }
97
106
  nil
98
107
  end
99
108
 
@@ -130,34 +139,38 @@ module Microsandbox
130
139
  # @return [nil]
131
140
  def ensure_runtime!
132
141
  return if @runtime_ready
133
- # A cloud backend has no local msb/libkrunfw runtime to provision: skip the
134
- # presence check and the first-use download entirely. Resolving the kind
135
- # uses the same lazy env/profile/config ladder every operation already
136
- # consults, so this adds no work for local hosts (the common case).
137
- return if default_backend_kind == :cloud
138
- # The binaries gem won the resolver: its vendored msb (and the libkrunfw
139
- # beside it) are exactly the version this gem was built for, so there is
140
- # nothing to verify or download. (If `MSB_PATH` overrides it, the user owns
141
- # the runtime and we fall through to the existing behaviour.)
142
- if bundled_runtime_active?
143
- @runtime_ready = true
144
- return
145
- end
146
- # Opted out: the caller manages the runtime out of band, so don't fetch,
147
- # verify, or repair it here. Memoize the decision (the env var is stable for
148
- # the process); the operation resolves `msb` itself and surfaces any problem.
149
- if auto_install_disabled?
150
- @runtime_ready = true
151
- return
152
- end
142
+ RUNTIME_SETUP_LOCK.synchronize do
143
+ # Re-check under the lock: another thread may have finished meanwhile.
144
+ return if @runtime_ready
145
+ # A cloud backend has no local msb/libkrunfw runtime to provision: skip the
146
+ # presence check and the first-use download entirely. Resolving the kind
147
+ # uses the same lazy env/profile/config ladder every operation already
148
+ # consults, so this adds no work for local hosts (the common case).
149
+ return if default_backend_kind == :cloud
150
+ # The binaries gem won the resolver: its vendored msb (and the libkrunfw
151
+ # beside it) are exactly the version this gem was built for, so there is
152
+ # nothing to verify or download. (If `MSB_PATH` overrides it, the user owns
153
+ # the runtime and we fall through to the existing behaviour.)
154
+ if bundled_runtime_active?
155
+ @runtime_ready = true
156
+ return
157
+ end
158
+ # Opted out: the caller manages the runtime out of band, so don't fetch,
159
+ # verify, or repair it here. Memoize the decision (the env var is stable for
160
+ # the process); the operation resolves `msb` itself and surfaces any problem.
161
+ if auto_install_disabled?
162
+ @runtime_ready = true
163
+ return
164
+ end
153
165
 
154
- unless installed?
155
- warn "[microsandbox] runtime (msb + libkrunfw) not found; " \
156
- "downloading to ~/.microsandbox (set MICROSANDBOX_NO_AUTO_INSTALL to skip, " \
157
- "or install the microsandbox-rb-binaries gem to ship it with your bundle)..."
166
+ unless installed?
167
+ warn "[microsandbox] runtime (msb + libkrunfw) not found; " \
168
+ "downloading to ~/.microsandbox (set MICROSANDBOX_NO_AUTO_INSTALL to skip, " \
169
+ "or install the microsandbox-rb-binaries gem to ship it with your bundle)..."
170
+ end
171
+ install
172
+ @runtime_ready = true
158
173
  end
159
- install
160
- @runtime_ready = true
161
174
  nil
162
175
  end
163
176
 
@@ -176,7 +189,9 @@ module Microsandbox
176
189
  # setter is a no-op — use `MSB_PATH` to override a bundled runtime.
177
190
  # @param path [String]
178
191
  # @return [void]
192
+ # @raise [ArgumentError] for nil or an empty path (the slot cannot be cleared)
179
193
  def runtime_path=(path)
194
+ require_runtime_path!(:runtime_path=, path)
180
195
  if @bundled_msb_path && path.to_s != @bundled_msb_path
181
196
  warn "[microsandbox] runtime_path= ignored: the microsandbox-rb-binaries gem already " \
182
197
  "claimed the set-once SDK slot (#{@bundled_msb_path}); set MSB_PATH to override it"
@@ -190,7 +205,9 @@ module Microsandbox
190
205
  # Mirrors {runtime_path=} for libkrunfw.
191
206
  # @param path [String]
192
207
  # @return [void]
208
+ # @raise [ArgumentError] for nil or an empty path (the slot cannot be cleared)
193
209
  def libkrunfw_path=(path)
210
+ require_runtime_path!(:libkrunfw_path=, path)
194
211
  Native.set_runtime_libkrunfw_path(path.to_s)
195
212
  end
196
213
 
@@ -272,6 +289,36 @@ module Microsandbox
272
289
  raise TypeError, "data must be a String (got #{data.class})"
273
290
  end
274
291
 
292
+ # A Time for an epoch-milliseconds Integer from the native layer, or nil.
293
+ # Integer arithmetic on purpose: `Time.at(ms / 1000.0)` goes through a
294
+ # Float and lands one millisecond low for about half of all timestamps.
295
+ # @api private
296
+ # @param ms [Integer, nil]
297
+ # @return [Time, nil]
298
+ def time_from_ms(ms)
299
+ ms && Time.at(ms.div(1000), ms % 1000, :millisecond)
300
+ end
301
+
302
+ # Run the block, then close +resource+ — the shared tail of the block forms
303
+ # that own a resource whose close can fail (a write sink flushes on close).
304
+ # If the block raised, its exception is the one the caller needs: a failure
305
+ # from the close that follows is dropped instead of replacing it. If the
306
+ # block finished (or left via break/throw), a close failure propagates.
307
+ # @api private
308
+ def close_after(resource)
309
+ raised = false
310
+ yield
311
+ rescue Exception # standard:disable Lint/RescueException -- re-raised unchanged
312
+ raised = true
313
+ raise
314
+ ensure
315
+ begin
316
+ resource.close
317
+ rescue
318
+ raise unless raised
319
+ end
320
+ end
321
+
275
322
  private
276
323
 
277
324
  # Auto-provisioning is on by default; any non-empty, non-"0"/"false" value
@@ -358,12 +405,22 @@ module Microsandbox
358
405
  private_constant :TRUSTED_BINARIES_OWNERS
359
406
 
360
407
  # Name of the loaded gem whose files define Microsandbox::Binaries, or nil
361
- # when it came from a bare load path.
408
+ # when it came from a bare load path. Gem roots can nest — a host project
409
+ # that is itself a gem, with its bundle installed under the project
410
+ # directory, is a prefix of every gem in that bundle — so the owner is the
411
+ # gem with the LONGEST matching root, not whichever one is listed first.
362
412
  def bundled_runtime_owner
363
413
  source = Binaries.method(:msb_path).source_location&.first
364
414
  return nil unless source
365
- spec = Gem.loaded_specs.values.find { |s| source.start_with?(File.join(s.full_gem_path, "")) }
366
- spec&.name
415
+ roots = Gem.loaded_specs.values.select { |s| source.start_with?(File.join(s.full_gem_path, "")) }
416
+ roots.max_by { |s| s.full_gem_path.length }&.name
417
+ end
418
+
419
+ # The runtime path slots are set-once in the core and have no "unset": nil
420
+ # or "" would be stored as an empty path for the rest of the process.
421
+ def require_runtime_path!(setter, path)
422
+ return unless path.nil? || path.to_s.empty?
423
+ raise ArgumentError, "#{setter} needs a non-empty path (the slot is set-once and cannot be cleared)"
367
424
  end
368
425
 
369
426
  # Whether the binaries gem's msb is what the resolver actually returns — i.e.