winproc 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Winproc
4
+ VERSION = "0.1.0"
5
+ end
data/lib/winproc.rb ADDED
@@ -0,0 +1,454 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "winproc/version"
4
+ require "winproc/winproc" # native extension: classes + errors + SLOT_*/SW_*/CREATE_* constants
5
+
6
+ # winproc — Windows process control for Ruby: job-object process trees that can
7
+ # never leak, ConPTY terminals, hygienic spawning, and UAC-aware elevation
8
+ # helpers — safe by default, cooperative with a fiber scheduler.
9
+ #
10
+ # require "winproc"
11
+ #
12
+ # Winproc::Job.new do |job| # kill_on_close: true (default)
13
+ # p = Winproc.spawn("ruby", "-e", "puts 6*7", stdout: :pipe, job: job)
14
+ # out = +""
15
+ # while (chunk = p.stdout.read) then out << chunk end
16
+ # p.wait # => 0
17
+ # p.close
18
+ # end # any stragglers die with the job
19
+ module Winproc
20
+ # A Windows API failure carries the originating error code (GetLastError /
21
+ # the failing return), set on the exception in C.
22
+ class OSError
23
+ def code
24
+ @code
25
+ end
26
+ end
27
+
28
+ # The four privilege names winproc supports (process-wide, scoped by
29
+ # #with_privilege). The token must already HOLD the privilege.
30
+ PRIVILEGE_NAMES = {
31
+ debug: "SeDebugPrivilege",
32
+ backup: "SeBackupPrivilege",
33
+ restore: "SeRestorePrivilege",
34
+ increase_quota: "SeIncreaseQuotaPrivilege"
35
+ }.freeze
36
+
37
+ module_function
38
+
39
+ # Run a blocking native call cooperatively. Under a Fiber scheduler (e.g.
40
+ # winloop) the call is offloaded to a worker Thread so the calling fiber parks
41
+ # (Thread#value routes through the scheduler's block/unblock hooks) and the
42
+ # event loop keeps serving other fibers; with no scheduler it runs inline (the
43
+ # C call already releases the GVL). On fiber unwind the worker is killed +
44
+ # joined so it can't leak or consume data destined for a later op.
45
+ #
46
+ # Caveat (same as winipc): a fiber unwound in the instant after the worker
47
+ # acquired a resource but before the value was delivered loses that
48
+ # acquisition — the inherent cancelled-read-already-pulled-bytes limitation.
49
+ def run_blocking
50
+ sched = Fiber.scheduler
51
+ return yield unless sched
52
+
53
+ worker = Thread.new do
54
+ Thread.current.report_on_exception = false
55
+ yield
56
+ end
57
+ begin
58
+ worker.value
59
+ ensure
60
+ if worker.alive?
61
+ worker.kill
62
+ worker.join
63
+ end
64
+ end
65
+ end
66
+
67
+ # Seconds -> milliseconds for the native waits. nil => -1 (INFINITE); a tiny
68
+ # positive value rounds up to 1 ms (never collapse a wait into a poll). A
69
+ # finite timeout always yields a finite, in-range ms: the result is clamped to
70
+ # the signed 64-bit ceiling the C waits accept (NUM2LL), so an absurdly large
71
+ # timeout becomes a very long finite wait (~292 million years) instead of a
72
+ # RangeError — never an INFINITE wait. Clamping here also means the in-flight
73
+ # guards in the C waits can never wedge on a conversion raise.
74
+ MS_MAX = 0x7FFF_FFFF_FFFF_FFFF # LLONG_MAX: the largest ms NUM2LL accepts
75
+
76
+ def ms_for(timeout)
77
+ return -1 if timeout.nil?
78
+
79
+ t = Float(timeout)
80
+ raise ArgumentError, "timeout must be non-negative, got #{timeout.inspect}" if t.negative?
81
+
82
+ ms = (t * 1000).round
83
+ return 1 if ms.zero? && t.positive?
84
+
85
+ ms > MS_MAX ? MS_MAX : ms
86
+ end
87
+
88
+ # Quote an argv Array into a single command line per the CommandLineToArgvW /
89
+ # CRT rules ("everyone quotes command line arguments the wrong way"). Pure,
90
+ # unit-tested. Raises ArgumentError on an empty argv or a NUL in any element.
91
+ #
92
+ # NOTE: these are the rules of the C runtime / CommandLineToArgvW parser.
93
+ # cmd.exe batch files and programs that parse their own command line
94
+ # differently (notably `cmd /c`) have their own metacharacter rules — winproc
95
+ # never invokes a shell and does not escape for cmd.exe.
96
+ def quote_argv(argv)
97
+ raise ArgumentError, "argv must not be empty" if argv.empty?
98
+
99
+ argv.map { |arg| quote_one(arg) }.join(" ")
100
+ end
101
+
102
+ def quote_one(arg)
103
+ s = String(arg)
104
+ raise ArgumentError, "argument contains a NUL byte: #{s.inspect}" if s.include?("\0")
105
+ return '""' if s.empty?
106
+ return s unless s.match?(/[ \t"]/)
107
+
108
+ out = +'"'
109
+ backslashes = 0
110
+ s.each_char do |ch|
111
+ case ch
112
+ when "\\"
113
+ backslashes += 1
114
+ when '"'
115
+ out << ("\\" * (backslashes * 2 + 1)) << '"'
116
+ backslashes = 0
117
+ else
118
+ out << ("\\" * backslashes) << ch
119
+ backslashes = 0
120
+ end
121
+ end
122
+ out << ("\\" * (backslashes * 2)) # trailing run, before the closing quote
123
+ out << '"'
124
+ out
125
+ end
126
+ private_class_method :quote_one
127
+
128
+ # Build the merged, sorted, NUL-joined environment block (a single UTF-8
129
+ # String with embedded NULs) from +env+ (Hash{String=>String|nil}) merged over
130
+ # the parent ENV case-insensitively, or nil to inherit unchanged.
131
+ def build_env_block(env)
132
+ return nil if env.nil?
133
+
134
+ merged = ENV.to_h
135
+ env.each do |key, value|
136
+ validate_env_key(key)
137
+ validate_env_value(value)
138
+ # delete any existing key that matches case-insensitively
139
+ merged.delete_if { |k, _| k.casecmp?(key) }
140
+ merged[key] = value unless value.nil?
141
+ end
142
+ # Sorted by upcased UTF-16 code-unit order (CreateProcessW requirement:
143
+ # "sorted alphabetically, case-insensitive, Unicode order, without regard to
144
+ # locale"). encode UTF-16LE to get code-unit ordering.
145
+ pairs = merged.sort_by { |k, _| k.upcase.encode("UTF-16LE") }
146
+ block = +""
147
+ pairs.each { |k, v| block << k << "=" << v << "\0" }
148
+ block
149
+ end
150
+ private_class_method :build_env_block
151
+
152
+ def validate_env_key(key)
153
+ raise TypeError, "env key must be a String, got #{key.class}" unless key.is_a?(String)
154
+ raise ArgumentError, "env key must not be empty" if key.empty?
155
+ raise ArgumentError, "env key must not contain '=': #{key.inspect}" if key.include?("=")
156
+ raise ArgumentError, "env key must not contain NUL: #{key.inspect}" if key.include?("\0")
157
+ end
158
+ private_class_method :validate_env_key
159
+
160
+ def validate_env_value(value)
161
+ return if value.nil?
162
+ raise TypeError, "env value must be a String or nil, got #{value.class}" unless value.is_a?(String)
163
+ raise ArgumentError, "env value must not contain NUL: #{value.inspect}" if value.include?("\0")
164
+ end
165
+ private_class_method :validate_env_value
166
+
167
+ # Encode a stdio kwarg into a [kind, io_or_std] pair for the C layer. +std+ is
168
+ # the GetStdHandle selector used when an INHERIT slot needs a parent std value.
169
+ def stdio_spec(value, allow_merge: false)
170
+ case value
171
+ when nil then [SLOT_INHERIT, nil]
172
+ when :null then [SLOT_NULL, nil]
173
+ when :pipe then [SLOT_PIPE, nil]
174
+ when :stdout
175
+ raise ArgumentError, "stdio :stdout is only valid for stderr:" unless allow_merge
176
+
177
+ [SLOT_MERGE, nil]
178
+ when IO then [SLOT_IO, value]
179
+ else
180
+ if value.respond_to?(:to_io)
181
+ [SLOT_IO, value.to_io]
182
+ else
183
+ raise ArgumentError, "stdio must be nil/:null/:pipe/an IO#{allow_merge ? '/:stdout' : ''}, " \
184
+ "got #{value.inspect}"
185
+ end
186
+ end
187
+ end
188
+ private_class_method :stdio_spec
189
+
190
+ # Spawn a child process from an argv ARRAY (no shell is ever invoked). Returns
191
+ # a Winproc::Process; with a block, yields it and ensure-closes the handles
192
+ # (closing does NOT kill the child — use job: for guaranteed reaping).
193
+ def spawn(*argv, app: nil, cwd: nil, env: nil,
194
+ stdin: nil, stdout: nil, stderr: nil,
195
+ job: nil, new_process_group: false, no_window: false)
196
+ cmdline = quote_argv(argv)
197
+ envblock = build_env_block(env)
198
+ ik, iio = stdio_spec(stdin)
199
+ ok, oio = stdio_spec(stdout)
200
+ ek, eio = stdio_spec(stderr, allow_merge: true)
201
+
202
+ flags = 0
203
+ flags |= CREATE_NEW_PROCESS_GROUP if new_process_group
204
+ flags |= CREATE_NO_WINDOW if no_window
205
+
206
+ process = _spawn(app && String(app), cmdline, cwd && String(cwd), envblock,
207
+ flags, job, 0, 0, ik, iio, ok, oio, ek, eio)
208
+ return process unless block_given?
209
+
210
+ begin
211
+ yield process
212
+ ensure
213
+ process.close
214
+ end
215
+ end
216
+
217
+ # Spawn a child attached to a ConPTY pseudoconsole. Returns a Winproc::PTY;
218
+ # with a block, yields it and ensure-closes (kill: true). stdio redirection
219
+ # kwargs are deliberately absent (ConPTY and pipe redirection are mutually
220
+ # exclusive).
221
+ def pty(*argv, cols: 80, rows: 24, app: nil, cwd: nil, env: nil, job: nil)
222
+ raise Unsupported, "winproc: ConPTY requires Windows 10 1809+" unless pty_available?
223
+
224
+ c = Integer(cols)
225
+ r = Integer(rows)
226
+ raise ArgumentError, "cols/rows must be 1..32767" unless (1..0x7FFF).cover?(c) && (1..0x7FFF).cover?(r)
227
+
228
+ cmdline = quote_argv(argv)
229
+ envblock = build_env_block(env)
230
+ pty = _spawn(app && String(app), cmdline, cwd && String(cwd), envblock,
231
+ 0, job, c, r,
232
+ SLOT_INHERIT, nil, SLOT_INHERIT, nil, SLOT_INHERIT, nil)
233
+ return pty unless block_given?
234
+
235
+ begin
236
+ yield pty
237
+ ensure
238
+ pty.close(kill: true)
239
+ end
240
+ end
241
+
242
+ # Is ConPTY available on this OS? (GetProcAddress probe; never raises.)
243
+ def pty_available?
244
+ __pty_available
245
+ end
246
+
247
+ # Is the current process running with an elevated token? (TokenElevation)
248
+ def elevated?
249
+ __elevated
250
+ end
251
+
252
+ # Is the current USER an administrator, even if not currently elevated?
253
+ def admin?
254
+ __admin
255
+ end
256
+
257
+ # Relaunch/launch a program elevated via the shell "runas" verb (UAC prompt).
258
+ # +args+ is an argv-style Array quoted by winproc with the same quote_argv
259
+ # rules as spawn (a String raises TypeError). Returns a Winproc::Process when
260
+ # the shell reports a new process handle; nil when it launched without one.
261
+ def runas(exe, args = [], cwd: nil, show: :normal)
262
+ raise TypeError, "runas args must be an Array, got #{args.class}" unless args.is_a?(Array)
263
+
264
+ params = args.empty? ? nil : quote_argv(args)
265
+ sw = case show
266
+ when :normal then SW_SHOWNORMAL
267
+ when :hide then SW_HIDE
268
+ when :minimized then SW_SHOWMINIMIZED
269
+ when :maximized then SW_SHOWMAXIMIZED
270
+ else raise ArgumentError, "show must be :normal/:hide/:minimized/:maximized, got #{show.inspect}"
271
+ end
272
+ run_blocking { __runas(String(exe), params, cwd && String(cwd), sw) }
273
+ end
274
+
275
+ # Enable a process-token privilege for the duration of the block, restoring its
276
+ # previous state afterwards (even on raise). The token is PROCESS-wide.
277
+ def with_privilege(name)
278
+ se = PRIVILEGE_NAMES[name]
279
+ raise ArgumentError, "unknown privilege #{name.inspect}; one of #{PRIVILEGE_NAMES.keys.inspect}" unless se
280
+
281
+ was_enabled = __privilege(se, true) # raises PrivilegeNotHeld if not held
282
+ begin
283
+ yield
284
+ ensure
285
+ __privilege(se, was_enabled)
286
+ end
287
+ end
288
+
289
+ # ---------------------------------------------------------------- Process ---
290
+ class Process
291
+ # Block until the process exits; returns the exit code (Integer). +timeout+
292
+ # in seconds (nil = infinite); returns nil on timeout. Cooperative under a
293
+ # scheduler. Memoizes the exit code.
294
+ def wait(timeout: nil)
295
+ Winproc.run_blocking { _wait(Winproc.ms_for(timeout)) }
296
+ end
297
+
298
+ # Hard kill (TerminateProcess); does NOT touch children (use a Job for trees).
299
+ def kill(exit_code = 1)
300
+ _kill(exit_code)
301
+ end
302
+
303
+ # The writable/readable pipe ends, present only for :pipe stdio slots.
304
+ def stdin = @stdin
305
+ def stdout = @stdout
306
+ def stderr = @stderr
307
+ end
308
+
309
+ # -------------------------------------------------------------------- Job ---
310
+ class Job
311
+ # Create an ANONYMOUS job object and apply limits. A private IOCP is created
312
+ # and associated with the job before any process can join, so #wait_empty
313
+ # can never miss messages.
314
+ def self.new(kill_on_close: true, memory: nil, process_memory: nil,
315
+ cpu_percent: nil, active_processes: nil, cpu_time: nil)
316
+ mem = memory && nonneg_int(memory, "memory")
317
+ pmem = process_memory && nonneg_int(process_memory, "process_memory")
318
+ act = active_processes && positive_int(active_processes, "active_processes")
319
+ cpu = cpu_percent && cpu_percent_int(cpu_percent)
320
+ time = cpu_time && cpu_time_ticks(cpu_time)
321
+
322
+ job = _create(kill_on_close, mem, pmem, cpu, act, time)
323
+ return job unless block_given?
324
+
325
+ begin
326
+ yield job
327
+ ensure
328
+ job.close
329
+ end
330
+ end
331
+
332
+ def self.nonneg_int(v, name)
333
+ i = Integer(v)
334
+ raise ArgumentError, "#{name} must be >= 0, got #{v.inspect}" if i.negative?
335
+
336
+ i
337
+ end
338
+ private_class_method :nonneg_int
339
+
340
+ def self.positive_int(v, name)
341
+ i = Integer(v)
342
+ raise ArgumentError, "#{name} must be >= 1, got #{v.inspect}" if i < 1
343
+
344
+ i
345
+ end
346
+ private_class_method :positive_int
347
+
348
+ def self.cpu_percent_int(v)
349
+ i = Integer(v)
350
+ raise ArgumentError, "cpu_percent must be 1..100, got #{v.inspect}" unless (1..100).cover?(i)
351
+
352
+ i
353
+ end
354
+ private_class_method :cpu_percent_int
355
+
356
+ # seconds (Float) -> user-mode CPU 100 ns ticks for PerJobUserTimeLimit.
357
+ def self.cpu_time_ticks(v)
358
+ t = Float(v)
359
+ raise ArgumentError, "cpu_time must be > 0 seconds, got #{v.inspect}" unless t.positive?
360
+
361
+ (t * 10_000_000).round
362
+ end
363
+ private_class_method :cpu_time_ticks
364
+
365
+ # AssignProcessToJobObject — the fallback path for a process NOT placed at
366
+ # creation. Prefer Winproc.spawn(job: job).
367
+ def assign(process)
368
+ _assign(process)
369
+ end
370
+
371
+ # TerminateJobObject: kill every process in the job (and nested child jobs).
372
+ def terminate(exit_code = 1)
373
+ _terminate(exit_code)
374
+ end
375
+
376
+ # Block until the job has zero active processes. true on empty, false on
377
+ # timeout. Only ONE wait_empty may be in flight per Job.
378
+ def wait_empty(timeout: nil)
379
+ Winproc.run_blocking { _wait_empty(Winproc.ms_for(timeout)) }
380
+ end
381
+ end
382
+
383
+ # ----------------------------------------------------------------- Stream ---
384
+ class Stream
385
+ # Up to +maxlen+ bytes as a binary (ASCII-8BIT) String; nil at EOF. Blocks
386
+ # until at least 1 byte. Cooperative under a scheduler.
387
+ def read(maxlen = 65_536)
388
+ Winproc.run_blocking { _read(maxlen) }
389
+ end
390
+
391
+ # Write ALL +bytes+ (loops on partial); returns the byte count. Binary-safe.
392
+ def write(bytes)
393
+ Winproc.run_blocking { _write(bytes) }
394
+ end
395
+
396
+ def <<(bytes)
397
+ write(bytes)
398
+ self
399
+ end
400
+ end
401
+
402
+ # -------------------------------------------------------------------- PTY ---
403
+ class PTY
404
+ # Raw bytes from the terminal output stream (UTF-8 text interleaved with VT
405
+ # escape sequences, verbatim). Binary (ASCII-8BIT) String; nil at EOF.
406
+ def read(maxlen = 65_536)
407
+ Winproc.run_blocking { @output._read(maxlen) }
408
+ end
409
+
410
+ # Bytes to the terminal input stream. Plain text = keystrokes; control keys
411
+ # are VT sequences. Send UTF-8. "\r" is Enter (not "\n").
412
+ def write(bytes)
413
+ Winproc.run_blocking { @input._write(bytes) }
414
+ end
415
+
416
+ # ResizePseudoConsole. 1..32767 each (COORD is SHORT).
417
+ def resize(cols, rows)
418
+ _resize(Integer(cols), Integer(rows))
419
+ end
420
+
421
+ # The child Process; use it for wait/kill/exitstatus.
422
+ def process = @process
423
+
424
+ # Deadlock-free teardown, exact order (§2.6):
425
+ # 1. kill: true and child alive -> process.kill
426
+ # 2. close the OUTPUT stream (cancels any in-flight read; the blocked
427
+ # reader raises Closed) — makes step 4 safe on pre-24H2 Windows
428
+ # 3. close the INPUT stream
429
+ # 4. ClosePseudoConsole(hPC) (GVL released; output pipe gone, no deadlock)
430
+ # 5. memoize the child's exit code if exited, then process.close
431
+ # Idempotent. After close, pty.process.exitstatus still answers (memoized).
432
+ def close(kill: true)
433
+ return nil if closed?
434
+
435
+ begin
436
+ @process.kill if kill && process_alive_safely?
437
+ rescue Winproc::Error
438
+ # best-effort; teardown must proceed
439
+ end
440
+ @output.close
441
+ @input.close
442
+ Winproc.run_blocking { _close_pty }
443
+ @process.close
444
+ nil
445
+ end
446
+
447
+ def process_alive_safely?
448
+ @process.alive?
449
+ rescue Winproc::Closed
450
+ false
451
+ end
452
+ private :process_alive_safely?
453
+ end
454
+ end
metadata ADDED
@@ -0,0 +1,122 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: winproc
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - ned
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rake
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '13.0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '13.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake-compiler
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.2'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.2'
40
+ - !ruby/object:Gem::Dependency
41
+ name: minitest
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '5.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '5.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: vcvars
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.1'
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: 0.1.1
64
+ type: :development
65
+ prerelease: false
66
+ version_requirements: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - "~>"
69
+ - !ruby/object:Gem::Version
70
+ version: '0.1'
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: 0.1.1
74
+ description: |
75
+ winproc is a native extension for controlling Windows processes the way the
76
+ OS intends: argv-array spawning with exact quoting and per-handle inheritance
77
+ (PROC_THREAD_ATTRIBUTE_HANDLE_LIST), job objects with kill-on-close so a
78
+ spawned tree can never outlive you (even across a crash), atomic job
79
+ placement at creation, ConPTY pseudoconsoles for real interactive terminal
80
+ I/O, and elevation helpers (elevated?/admin?, ShellExecuteEx "runas",
81
+ scoped token privileges). Blocking waits release the GVL and cooperate with
82
+ a fiber scheduler. Windows MSVC (mswin) Ruby only.
83
+ executables: []
84
+ extensions:
85
+ - ext/winproc/extconf.rb
86
+ extra_rdoc_files: []
87
+ files:
88
+ - CHANGELOG.md
89
+ - LICENSE.txt
90
+ - README.md
91
+ - ext/winproc/extconf.rb
92
+ - ext/winproc/winproc.c
93
+ - lib/winproc.rb
94
+ - lib/winproc/version.rb
95
+ homepage: https://github.com/main-path/winproc
96
+ licenses:
97
+ - MIT
98
+ metadata:
99
+ homepage_uri: https://github.com/main-path/winproc
100
+ source_code_uri: https://github.com/main-path/winproc
101
+ changelog_uri: https://github.com/main-path/winproc/blob/main/CHANGELOG.md
102
+ bug_tracker_uri: https://github.com/main-path/winproc/issues
103
+ rubygems_mfa_required: 'true'
104
+ rdoc_options: []
105
+ require_paths:
106
+ - lib
107
+ required_ruby_version: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: '3.1'
112
+ required_rubygems_version: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '0'
117
+ requirements: []
118
+ rubygems_version: 3.6.9
119
+ specification_version: 4
120
+ summary: 'Windows process control for Ruby: job-object process trees, ConPTY terminals,
121
+ hygienic spawning, and UAC elevation helpers.'
122
+ test_files: []