microsandbox-rb 0.5.8 → 0.5.10

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.
@@ -1,41 +1,119 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Microsandbox
4
- # Lightweight metadata about a sandbox, returned by {Sandbox.get} and
5
- # {Sandbox.list}. This is a snapshot, not a live handle.
6
- class SandboxInfo
4
+ # A controllable handle to a sandbox, returned by {Sandbox.get}, {Sandbox.list},
5
+ # and {Sandbox.list_with}. Carries a metadata snapshot (captured when fetched)
6
+ # plus the fine-grained lifecycle surface — `stop_with_timeout`, `request_stop`,
7
+ # `request_kill`, `request_drain`, `wait_until_stopped` — that mirrors the
8
+ # official SDKs' `SandboxHandle`. (The live {Sandbox} from {Sandbox.create}/
9
+ # {Sandbox.start} carries only the high-level `stop`/`kill`/`drain`/`wait`.)
10
+ #
11
+ # As of v0.5.8 this replaces the old read-only `SandboxInfo` (kept as a
12
+ # deprecated constant alias); `#status` here is a synchronous snapshot.
13
+ class SandboxHandle
14
+ def initialize(native)
15
+ @native = native
16
+ end
17
+
7
18
  # @return [String]
8
- attr_reader :name
9
- # @return [Symbol] :running, :draining, :paused, :stopped, or :crashed
10
- attr_reader :status
19
+ def name = @native.name
11
20
 
12
- def initialize(data)
13
- @name = data["name"]
14
- @status = data["status"].to_sym
15
- @created_at_ms = data["created_at_ms"]
16
- @updated_at_ms = data["updated_at_ms"]
17
- end
21
+ # @return [Symbol] :created, :starting, :running, :draining, :paused,
22
+ # :stopped, or :crashed (a snapshot, captured when this handle was fetched)
23
+ def status = @native.status.to_sym
18
24
 
19
- def running? = @status == :running
20
- def stopped? = @status == :stopped
25
+ # Whether the fetch-time {#status} snapshot is `:running` / `:stopped`.
26
+ # Like {#status}, these do NOT refresh: to observe a state change after
27
+ # {#request_stop}/{#request_kill}/{#request_drain}, use {#wait_until_stopped}
28
+ # or re-fetch the handle with {Sandbox.get}.
29
+ def running? = status == :running
30
+ def stopped? = status == :stopped
21
31
 
22
32
  # @return [Time, nil]
23
33
  def created_at
24
- @created_at_ms && Time.at(@created_at_ms / 1000.0)
34
+ ms = @native.created_at_ms
35
+ ms && Time.at(ms / 1000.0)
25
36
  end
26
37
 
27
38
  # @return [Time, nil]
28
39
  def updated_at
29
- @updated_at_ms && Time.at(@updated_at_ms / 1000.0)
40
+ ms = @native.updated_at_ms
41
+ ms && Time.at(ms / 1000.0)
42
+ end
43
+
44
+ # Gracefully stop the sandbox (SIGTERM→SIGKILL escalation, 10s default).
45
+ # @return [nil]
46
+ def stop
47
+ @native.stop
48
+ nil
49
+ end
50
+
51
+ # Gracefully stop with a custom escalation timeout.
52
+ # @param timeout [Numeric] seconds to wait before escalating to SIGKILL
53
+ # @return [nil]
54
+ def stop_with_timeout(timeout)
55
+ @native.stop_with_timeout(Sandbox.send(:coerce_duration, timeout, "timeout"))
56
+ nil
57
+ end
58
+
59
+ # Force-kill the sandbox (SIGKILL).
60
+ # @return [nil]
61
+ def kill
62
+ @native.kill
63
+ nil
64
+ end
65
+
66
+ # Force-kill, waiting up to `timeout` seconds for the process to disappear.
67
+ # @param timeout [Numeric]
68
+ # @return [nil]
69
+ def kill_with_timeout(timeout)
70
+ @native.kill_with_timeout(Sandbox.send(:coerce_duration, timeout, "timeout"))
71
+ nil
72
+ end
73
+
74
+ # Send the graceful-shutdown request and return immediately, without waiting.
75
+ # Pair with {#wait_until_stopped}.
76
+ # @return [nil]
77
+ def request_stop
78
+ @native.request_stop
79
+ nil
80
+ end
81
+
82
+ # Send the force-kill request and return immediately, without waiting.
83
+ # @return [nil]
84
+ def request_kill
85
+ @native.request_kill
86
+ nil
87
+ end
88
+
89
+ # Request a graceful drain (SIGUSR1) and return immediately, without waiting.
90
+ # @return [nil]
91
+ def request_drain
92
+ @native.request_drain
93
+ nil
94
+ end
95
+
96
+ # Block until the sandbox is observed in a terminal (non-running) state.
97
+ # @return [SandboxStopResult]
98
+ def wait_until_stopped
99
+ SandboxStopResult.new(@native.wait_until_stopped)
30
100
  end
31
101
 
32
102
  def inspect
33
- "#<Microsandbox::SandboxInfo name=#{@name.inspect} status=#{@status}>"
103
+ "#<Microsandbox::SandboxHandle name=#{name.inspect} status=#{status}>"
34
104
  end
35
105
  end
36
106
 
107
+ # @deprecated since v0.5.8. {Sandbox.get}/{Sandbox.list} now return a
108
+ # controllable {SandboxHandle}; this constant remains as an alias so code
109
+ # that referenced the old read-only metadata type by name (e.g. `is_a?`
110
+ # checks) still resolves. Note it is now the same class as {SandboxHandle},
111
+ # whose constructor takes a native handle, not the metadata Hash the old
112
+ # `SandboxInfo.new` accepted — construct via {Sandbox.get}/{Sandbox.list}.
113
+ SandboxInfo = SandboxHandle
114
+
37
115
  # The terminal observation of a stopped sandbox, returned by
38
- # {Sandbox#wait_until_stopped}. Mirrors the official SDKs' `SandboxStopResult`.
116
+ # {SandboxHandle#wait_until_stopped}. Mirrors the official SDKs' `SandboxStopResult`.
39
117
  class SandboxStopResult
40
118
  # @return [String]
41
119
  attr_reader :name
@@ -67,7 +145,7 @@ module Microsandbox
67
145
 
68
146
  def inspect
69
147
  "#<Microsandbox::SandboxStopResult name=#{@name.inspect} status=#{@status}" \
70
- "#{@exit_code ? " exit_code=#{@exit_code}" : ""}#{@signal ? " signal=#{@signal}" : ""}>"
148
+ "#{" exit_code=#{@exit_code}" if @exit_code}#{" signal=#{@signal}" if @signal}>"
71
149
  end
72
150
  end
73
151
 
@@ -108,8 +186,15 @@ module Microsandbox
108
186
  # @param entrypoint [Array<String>, nil] image entrypoint override
109
187
  # @param ports [Hash, nil] host_port => guest_port TCP publications
110
188
  # @param ports_udp [Hash, nil] host_port => guest_port UDP publications
111
- # @param network ["public_only", "none", "allow_all", "non_local", nil] network
112
- # policy preset (default public_only)
189
+ # @param network [String, Symbol, NetworkPolicy, Hash, nil] network policy.
190
+ # A preset name ("public_only" (default), "none", "allow_all",
191
+ # "non_local"), a {NetworkPolicy} (e.g. {NetworkPolicy.custom}), or a Hash
192
+ # describing a custom policy (`default_egress:`, `default_ingress:`,
193
+ # `rules:`, `deny_domains:`, `deny_domain_suffixes:`). See {NetworkPolicy}
194
+ # and {Rule}.
195
+ # @param patches [Array<Hash>, nil] rootfs patches applied before boot, each
196
+ # built with the {Patch} factory (e.g. `Patch.text(...)`, `Patch.mkdir(...)`).
197
+ # Not compatible with disk-image roots.
113
198
  # @param log_level ["error","warn","info","debug","trace", nil] guest log verbosity
114
199
  # @param quiet_logs [Boolean] suppress sandbox process logs
115
200
  # @param security ["default", "restricted", nil] exec security profile
@@ -136,14 +221,21 @@ module Microsandbox
136
221
  # @yieldparam sandbox [Sandbox]
137
222
  # @return [Sandbox, Object] the sandbox, or the block's return value
138
223
  def create(name,
139
- image: nil, cpus: nil, memory: nil, env: nil, workdir: nil,
140
- shell: nil, user: nil, hostname: nil, labels: nil, scripts: nil,
141
- entrypoint: nil, ports: nil, ports_udp: nil, volumes: nil, network: nil,
142
- from_snapshot: nil, log_level: nil, quiet_logs: false, security: nil,
143
- oci_upper_size: nil, max_duration: nil, idle_timeout: nil, rlimits: nil,
144
- pull_policy: nil, registry_auth: nil, registry_insecure: false,
145
- registry_ca_certs: nil, secrets: nil,
146
- detached: false, replace: false, replace_with_timeout: nil)
224
+ image: nil, cpus: nil, memory: nil, env: nil, workdir: nil,
225
+ shell: nil, user: nil, hostname: nil, labels: nil, scripts: nil,
226
+ entrypoint: nil, ports: nil, ports_udp: nil, volumes: nil, network: nil,
227
+ patches: nil,
228
+ from_snapshot: nil, log_level: nil, quiet_logs: false, security: nil,
229
+ oci_upper_size: nil, max_duration: nil, idle_timeout: nil, rlimits: nil,
230
+ pull_policy: nil, registry_auth: nil, registry_insecure: false,
231
+ registry_ca_certs: nil, secrets: nil,
232
+ detached: false, replace: false, replace_with_timeout: nil)
233
+ # A sandbox boots from exactly one rootfs source. The core would reject a
234
+ # contradictory pair, but only after a runtime round-trip; fail fast and
235
+ # clearly here (the Python SDK validates this the same way).
236
+ if image && from_snapshot
237
+ raise ArgumentError, "provide either image: or from_snapshot:, not both"
238
+ end
147
239
  Microsandbox.ensure_runtime!
148
240
  opts = {}
149
241
  opts["image"] = image.to_s if image
@@ -161,7 +253,8 @@ module Microsandbox
161
253
  opts["ports"] = intify_ports(ports) if ports
162
254
  opts["ports_udp"] = intify_ports(ports_udp) if ports_udp
163
255
  opts["volumes"] = normalize_volumes(volumes) if volumes
164
- opts["network"] = network.to_s if network
256
+ opts["patches"] = normalize_patches(patches) if patches
257
+ apply_network_opts(opts, network) unless network.nil?
165
258
  opts["log_level"] = log_level.to_s if log_level
166
259
  opts["quiet_logs"] = true if quiet_logs
167
260
  opts["security"] = security.to_s if security
@@ -174,7 +267,7 @@ module Microsandbox
174
267
  opts["secrets"] = normalize_secrets(secrets) if secrets
175
268
  opts["detached"] = true if detached
176
269
  if replace_with_timeout
177
- opts["replace_with_timeout"] = Float(replace_with_timeout)
270
+ opts["replace_with_timeout"] = coerce_duration(replace_with_timeout, "replace_with_timeout")
178
271
  elsif replace
179
272
  opts["replace"] = true
180
273
  end
@@ -197,27 +290,27 @@ module Microsandbox
197
290
  # @return [Sandbox]
198
291
  def start(name, detached: false)
199
292
  Microsandbox.ensure_runtime!
200
- new(Native::Sandbox.start(name.to_s, { "detached" => detached }))
293
+ new(Native::Sandbox.start(name.to_s, {"detached" => detached}))
201
294
  end
202
295
 
203
- # Fetch metadata for a sandbox by name.
204
- # @return [SandboxInfo]
296
+ # Fetch a controllable handle for a sandbox by name (running or not).
297
+ # @return [SandboxHandle]
205
298
  def get(name)
206
- SandboxInfo.new(Native::Sandbox.get(name.to_s))
299
+ SandboxHandle.new(Native::Sandbox.get(name.to_s))
207
300
  end
208
301
 
209
- # List all sandboxes.
210
- # @return [Array<SandboxInfo>]
302
+ # List all sandboxes as controllable handles.
303
+ # @return [Array<SandboxHandle>]
211
304
  def list
212
- Native::Sandbox.list.map { |info| SandboxInfo.new(info) }
305
+ Native::Sandbox.list.map { |h| SandboxHandle.new(h) }
213
306
  end
214
307
 
215
308
  # List sandboxes carrying all of the given labels (AND-matched).
216
309
  # @param labels [Hash] required key => value labels
217
- # @return [Array<SandboxInfo>]
310
+ # @return [Array<SandboxHandle>]
218
311
  def list_with(labels: {})
219
- opts = { "labels" => stringify(labels) }
220
- Native::Sandbox.list_with(opts).map { |info| SandboxInfo.new(info) }
312
+ opts = {"labels" => stringify(labels)}
313
+ Native::Sandbox.list_with(opts).map { |h| SandboxHandle.new(h) }
221
314
  end
222
315
 
223
316
  # Remove a (stopped) sandbox by name.
@@ -229,6 +322,19 @@ module Microsandbox
229
322
 
230
323
  private
231
324
 
325
+ # Coerce a seconds value to a finite, non-negative Float. Rejects negatives,
326
+ # NaN, and infinities *here* (a clean ArgumentError) rather than letting them
327
+ # reach the native layer, where `Duration::from_secs_f64` panics across the
328
+ # FFI boundary on exactly those inputs. Shared by every duration option.
329
+ def coerce_duration(value, label)
330
+ seconds = Float(value)
331
+ unless seconds.finite? && seconds >= 0
332
+ raise ArgumentError,
333
+ "#{label} must be a finite, non-negative number of seconds (got #{value.inspect})"
334
+ end
335
+ seconds
336
+ end
337
+
232
338
  def stringify(hash)
233
339
  hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v.to_s }
234
340
  end
@@ -247,7 +353,7 @@ module Microsandbox
247
353
  unless username && password
248
354
  # Report only the keys given, never the values — auth carries secrets.
249
355
  raise ArgumentError,
250
- "registry_auth needs :username and :password (got keys: #{auth.keys.inspect})"
356
+ "registry_auth needs :username and :password (got keys: #{auth.keys.inspect})"
251
357
  end
252
358
  opts["registry_username"] = username.to_s
253
359
  opts["registry_password"] = password.to_s
@@ -302,6 +408,33 @@ module Microsandbox
302
408
  end
303
409
  end
304
410
  end
411
+
412
+ # Normalize a list of patches (each a Hash from the {Patch} factory, or a
413
+ # plain Hash) into string-keyed Hashes for the native layer. Values are
414
+ # passed through unchanged (mode stays Integer, content stays String).
415
+ def normalize_patches(patches)
416
+ Array(patches).map do |p|
417
+ unless p.is_a?(Hash)
418
+ raise ArgumentError, "patch must be a Hash (use Microsandbox::Patch.*): #{p.inspect}"
419
+ end
420
+ p.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
421
+ end
422
+ end
423
+
424
+ # Route the `network:` argument to either the preset path
425
+ # (`opts["network"]`, the original string-preset behavior) or the custom
426
+ # policy path (`opts["network_policy"]`). Accepts a preset String/Symbol, a
427
+ # {NetworkPolicy}, or a plain Hash.
428
+ def apply_network_opts(opts, network)
429
+ norm = NetworkPolicy.coerce(network)
430
+ return if norm.empty? # e.g. network: {} — leave the default policy in place
431
+
432
+ if norm.keys == ["preset"]
433
+ opts["network"] = norm["preset"]
434
+ else
435
+ opts["network_policy"] = norm
436
+ end
437
+ end
305
438
  end
306
439
 
307
440
  def initialize(native)
@@ -322,33 +455,78 @@ module Microsandbox
322
455
  # @param env [Hash, nil] extra environment variables
323
456
  # @param timeout [Numeric, nil] kill after N seconds
324
457
  # @param tty [Boolean] allocate a pseudo-terminal
325
- # @param stdin [String, nil] data to feed to stdin
458
+ # @param stdin [String, Symbol, nil] bytes to feed to stdin, or +:pipe+ to
459
+ # open a streaming stdin pipe (write/close it via {ExecHandle#stdin}; only
460
+ # useful with the streaming variants)
326
461
  # @return [ExecOutput]
327
462
  def exec(command, args = [], cwd: nil, user: nil, env: nil, timeout: nil, tty: false, stdin: nil, rlimits: nil)
328
463
  ExecOutput.new(@native.exec(command.to_s, Array(args).map(&:to_s),
329
- exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:)))
464
+ exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:)))
330
465
  end
331
466
 
332
467
  # Run a shell script (pipes, redirects, etc. allowed) and collect output.
333
468
  # @return [ExecOutput]
334
469
  def shell(script, cwd: nil, user: nil, env: nil, timeout: nil, tty: false, stdin: nil, rlimits: nil)
335
470
  ExecOutput.new(@native.shell(script.to_s,
336
- exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:)))
471
+ exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:)))
337
472
  end
338
473
 
339
474
  # Run a command and stream its output as it arrives.
475
+ #
476
+ # Pass +stdin: :pipe+ to feed the process interactively: {ExecHandle#stdin}
477
+ # then returns a writable sink; close it to send EOF (a process like +cat+
478
+ # that reads until EOF will otherwise block forever).
340
479
  # @return [ExecHandle]
341
480
  # @see ExecHandle
342
481
  def exec_stream(command, args = [], cwd: nil, user: nil, env: nil, timeout: nil, tty: false, stdin: nil, rlimits: nil)
343
482
  ExecHandle.new(@native.exec_stream(command.to_s, Array(args).map(&:to_s),
344
- exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:)))
483
+ exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:, pipe_ok: true)))
345
484
  end
346
485
 
347
486
  # Run a shell script and stream its output as it arrives.
348
487
  # @return [ExecHandle]
349
488
  def shell_stream(script, cwd: nil, user: nil, env: nil, timeout: nil, tty: false, stdin: nil, rlimits: nil)
350
489
  ExecHandle.new(@native.shell_stream(script.to_s,
351
- exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:)))
490
+ exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:, pipe_ok: true)))
491
+ end
492
+
493
+ # Attach an interactive terminal to a command in the sandbox.
494
+ #
495
+ # Puts the **host** terminal into raw mode and forwards keystrokes (and
496
+ # SIGWINCH resizes) to the guest until the command exits or the detach
497
+ # sequence is typed. Requires a real TTY on stdin/stdout, so it is for CLI
498
+ # use, not library/automation code (use {#exec}/{#exec_stream} there). Blocks
499
+ # until the session ends. Mirrors the official SDKs' `attach`.
500
+ #
501
+ # @param command [String] the program to run
502
+ # @param args [Array<String>] its arguments
503
+ # @param cwd [String, nil] working directory
504
+ # @param user [String, nil] user to run as
505
+ # @param env [Hash, nil] extra environment variables
506
+ # @param detach_keys [String, nil] detach sequence (e.g. "ctrl-p,ctrl-q";
507
+ # default "ctrl-]")
508
+ # @param rlimits [Hash, nil] resource limits (see {#exec})
509
+ # @return [Integer] the command's exit code (or the code at detach)
510
+ def attach(command, args = [], cwd: nil, user: nil, env: nil, detach_keys: nil, rlimits: nil)
511
+ opts = {}
512
+ opts["cwd"] = cwd.to_s if cwd
513
+ opts["user"] = user.to_s if user
514
+ opts["env"] = env.each_with_object({}) { |(k, v), a| a[k.to_s] = v.to_s } if env
515
+ opts["detach_keys"] = detach_keys.to_s if detach_keys
516
+ if rlimits
517
+ opts["rlimits"] = rlimits.map do |resource, limit|
518
+ soft, hard = limit.is_a?(Array) ? [limit[0], limit[1]] : [limit, limit]
519
+ [resource.to_s, Integer(soft), Integer(hard)]
520
+ end
521
+ end
522
+ @native.attach(command.to_s, Array(args).map(&:to_s), opts)
523
+ end
524
+
525
+ # Attach an interactive terminal running the sandbox's default shell.
526
+ # See {#attach} for the host-TTY requirements.
527
+ # @return [Integer] the shell's exit code (or the code at detach)
528
+ def attach_shell
529
+ @native.attach_shell
352
530
  end
353
531
 
354
532
  # Guest filesystem operations.
@@ -357,6 +535,15 @@ module Microsandbox
357
535
  @fs ||= FS.new(@native)
358
536
  end
359
537
 
538
+ # SSH access to the sandbox — open a native in-process SSH client or prepare
539
+ # a reusable server endpoint.
540
+ # @return [SshOps]
541
+ # @example
542
+ # sb.ssh.open_client { |c| puts c.exec("hostname").stdout }
543
+ def ssh
544
+ SshOps.new(@native)
545
+ end
546
+
360
547
  # Latest resource-usage snapshot.
361
548
  # @return [Metrics]
362
549
  def metrics
@@ -385,7 +572,7 @@ module Microsandbox
385
572
  # @param interval [Numeric] seconds between snapshots
386
573
  # @return [MetricsStream] an {Enumerable} of {Metrics}
387
574
  def metrics_stream(interval: 1.0)
388
- MetricsStream.new(@native.metrics_stream(Float(interval)))
575
+ MetricsStream.new(@native.metrics_stream(coerce_duration(interval, "interval")))
389
576
  end
390
577
 
391
578
  # Stream captured logs as they appear.
@@ -408,48 +595,46 @@ module Microsandbox
408
595
  LogStream.new(@native.log_stream(opts))
409
596
  end
410
597
 
411
- # Gracefully stop the sandbox (and wait for it to terminate).
412
- # @param timeout [Numeric, nil] seconds to wait before SIGKILL
598
+ # Gracefully stop the sandbox (SIGTERM→SIGKILL escalation, 10s default) and
599
+ # wait for it to terminate. For a custom timeout or fire-and-return
600
+ # `request_*` control, fetch a {SandboxHandle} via {Sandbox.get}.
413
601
  # @return [nil]
414
- def stop(timeout: nil)
415
- @native.stop(timeout && Float(timeout))
602
+ def stop
603
+ @native.stop
416
604
  nil
417
605
  end
418
606
 
419
- # Force-kill the sandbox (SIGKILL).
420
- # @param timeout [Numeric, nil] seconds to wait
421
- # @return [nil]
422
- def kill(timeout: nil)
423
- @native.kill(timeout && Float(timeout))
424
- nil
607
+ # Gracefully stop, then wait for the process to exit.
608
+ # @return [ExitStatus]
609
+ def stop_and_wait
610
+ ExitStatus.new(@native.stop_and_wait)
425
611
  end
426
612
 
427
- # Send the graceful-shutdown request and return immediately, without waiting
428
- # for the sandbox to terminate. Pair with {#wait_until_stopped}.
613
+ # Force-kill the sandbox (SIGKILL).
429
614
  # @return [nil]
430
- def request_stop
431
- @native.request_stop
615
+ def kill
616
+ @native.kill
432
617
  nil
433
618
  end
434
619
 
435
- # Send the force-kill request and return immediately, without waiting.
620
+ # Trigger a graceful drain (SIGUSR1).
436
621
  # @return [nil]
437
- def request_kill
438
- @native.request_kill
622
+ def drain
623
+ @native.drain
439
624
  nil
440
625
  end
441
626
 
442
- # Request a graceful drain and return immediately, without waiting.
443
- # @return [nil]
444
- def request_drain
445
- @native.request_drain
446
- nil
627
+ # Wait for the sandbox process to exit.
628
+ # @return [ExitStatus]
629
+ def wait
630
+ ExitStatus.new(@native.wait)
447
631
  end
448
632
 
449
- # Block until the sandbox is observed in a terminal (non-running) state.
450
- # @return [SandboxStopResult]
451
- def wait_until_stopped
452
- SandboxStopResult.new(@native.wait_until_stopped)
633
+ # The live status, fetched from the backend (a round-trip per call).
634
+ # @return [Symbol] :created, :starting, :running, :draining, :paused,
635
+ # :stopped, or :crashed
636
+ def status
637
+ @native.status.to_sym
453
638
  end
454
639
 
455
640
  # @return [Boolean] whether this handle owns the sandbox process lifecycle
@@ -472,14 +657,37 @@ module Microsandbox
472
657
 
473
658
  private
474
659
 
475
- def exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:)
660
+ # Instance-side shim for the shared, class-private duration validator (used
661
+ # by #exec/#shell timeouts, #stop/#kill, and #metrics_stream).
662
+ def coerce_duration(value, label)
663
+ self.class.send(:coerce_duration, value, label)
664
+ end
665
+
666
+ def exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:, pipe_ok: false)
476
667
  opts = {}
477
668
  opts["cwd"] = cwd.to_s if cwd
478
669
  opts["user"] = user.to_s if user
479
670
  opts["env"] = env.each_with_object({}) { |(k, v), a| a[k.to_s] = v.to_s } if env
480
- opts["timeout"] = Float(timeout) if timeout
671
+ opts["timeout"] = coerce_duration(timeout, "timeout") if timeout
481
672
  opts["tty"] = true if tty
482
- opts["stdin"] = stdin.to_s if stdin
673
+ # `stdin: :pipe` opens a streaming stdin pipe — write to it via
674
+ # {ExecHandle#stdin} and close to send EOF. It is only meaningful for the
675
+ # streaming variants (which return an ExecHandle); a blocking exec/shell
676
+ # collects to completion and has nowhere to hand back the sink, so a piped
677
+ # process that reads stdin would block forever waiting for EOF. Reject it
678
+ # there. Any other truthy value is fed as a fixed byte buffer (closed
679
+ # automatically). nil means no stdin.
680
+ case stdin
681
+ when nil then nil
682
+ when :pipe
683
+ unless pipe_ok
684
+ raise ArgumentError,
685
+ "stdin: :pipe is only valid for exec_stream/shell_stream — a blocking " \
686
+ "exec/shell cannot expose a writable stdin sink; pass a String to feed bytes"
687
+ end
688
+ opts["stdin_pipe"] = true
689
+ else opts["stdin"] = stdin.to_s
690
+ end
483
691
  if rlimits
484
692
  opts["rlimits"] = rlimits.map do |resource, limit|
485
693
  soft, hard = limit.is_a?(Array) ? [limit[0], limit[1]] : [limit, limit]
@@ -43,7 +43,7 @@ module Microsandbox
43
43
  end
44
44
 
45
45
  def inspect
46
- "#<Microsandbox::SnapshotInfo digest=#{@digest.inspect}#{@name ? " name=#{@name.inspect}" : ""}>"
46
+ "#<Microsandbox::SnapshotInfo digest=#{@digest.inspect}#{" name=#{@name.inspect}" if @name}>"
47
47
  end
48
48
  end
49
49
 
@@ -142,7 +142,7 @@ module Microsandbox
142
142
  # @param dest [String, nil] explicit destination directory
143
143
  # @return [SnapshotInfo]
144
144
  def import(archive_path, dest: nil)
145
- SnapshotInfo.new(Native::Snapshot.import(archive_path.to_s, dest && dest.to_s))
145
+ SnapshotInfo.new(Native::Snapshot.import(archive_path.to_s, dest&.to_s))
146
146
  end
147
147
 
148
148
  private