From 93829ff9ebd07499cb3624f393df6ad126a3daad Mon Sep 17 00:00:00 2001 From: Brad Byrd Date: Sun, 20 Sep 2026 03:21:15 -0500 Subject: [PATCH 1/6] test(process): reproduce isolated execution and result requirements --- .../evidence/2026-09-20-process-lifecycle.md | 37 ++++++++++++++++ TestPrograms/process/.wflcfg | 8 ++++ TestPrograms/process/lifecycle.test.wfl | 43 +++++++++++++++++++ tests/fixtures/process/cwd.wfl | 2 + tests/fixtures/process/error.wfl | 3 ++ 5 files changed, 93 insertions(+) create mode 100644 Engineering/evidence/2026-09-20-process-lifecycle.md create mode 100644 TestPrograms/process/.wflcfg create mode 100644 TestPrograms/process/lifecycle.test.wfl create mode 100644 tests/fixtures/process/cwd.wfl create mode 100644 tests/fixtures/process/error.wfl diff --git a/Engineering/evidence/2026-09-20-process-lifecycle.md b/Engineering/evidence/2026-09-20-process-lifecycle.md new file mode 100644 index 00000000..8c839d8c --- /dev/null +++ b/Engineering/evidence/2026-09-20-process-lifecycle.md @@ -0,0 +1,37 @@ +# Subprocess lifecycle acceptance evidence + +Risk: R3, process ownership, cancellation, public syntax and CLI exit behavior. +Provider: WFL. Consumer: Scriptorium's required WFL-only test runner and HTTP +integration driver. No application or Python test-driver workaround is used. + +## Behavioral baseline before implementation + +Base: `cb1dadaad96939a4450a6eb2b3a6a51678035b7f`. +Runtime: official Windows nightly `26.9.12`, with its bin directory first on +`PATH` so the parent and all child runtimes match. Date: 2026-09-20. + +Command: `wfl --test TestPrograms/process/lifecycle.test.wfl`. +Observed: 3 tests, 0 passed, 3 failed, exit 1. All programs parsed and executed. + +1. Absolute fixture source path still used the repository working directory; + assertion expected the isolated fixture directory. +2. Numeric wait obtained child exit 0, but subsequent output retrieval raised + `Invalid process ID`; final-output assertion found empty text. +3. Foreground execution demonstrated a real `Division by zero` stderr + diagnostic. Background execution returned only its stdout marker; assertion + for that diagnostic failed even though child exit 1 was observed. + +These establish the behavioral requirements. The implementation will add an +explicit working-directory clause and full-result completion, preserving the +existing numeric wait's release behavior. Green tests will use the additive +API: retaining every legacy completion indefinitely would break bounded +ownership, while silently dropping retained output would hide failures. + +Planned contract: direct argv, optional per-launch cwd, finite explicit wait +timeout, joined bounded stdout/stderr result and exit status, atomic release, +idempotent close, reliable kill/reap on errors, and clean explicit program exit +codes. Existing subprocess opt-in policy remains authoritative. Tests, fixture +generation and assertions are WFL; existing Rust harnesses may discover them. + +Green, compatibility, resource, platform, review and final CI evidence remain +pending. This document is not completion evidence. diff --git a/TestPrograms/process/.wflcfg b/TestPrograms/process/.wflcfg new file mode 100644 index 00000000..8e01bc44 --- /dev/null +++ b/TestPrograms/process/.wflcfg @@ -0,0 +1,8 @@ +# Trusted, synthetic subprocess conformance fixtures only. +allow_shell_execution = true +shell_execution_mode = sanitized +timeout_seconds = 60 +logging_enabled = false +debug_report_enabled = false +kill_on_shutdown = true +max_buffer_size_bytes = 1024 diff --git a/TestPrograms/process/lifecycle.test.wfl b/TestPrograms/process/lifecycle.test.wfl new file mode 100644 index 00000000..736b53ff --- /dev/null +++ b/TestPrograms/process/lifecycle.test.wfl @@ -0,0 +1,43 @@ +// Behavioral Red: existing grammar demonstrates the requirements that need +// additive cwd and full-result completion APIs. Legacy numeric wait will keep +// its existing releasing behavior; the Green suite will use the new API. +store repo_root as path_dirname of (path_dirname of script_directory) +store fixture_root as path_join of repo_root and "tests/fixtures/process" +describe "Subprocess requirements before the additive lifecycle API": + test "a fixture runs in its own working directory": + store child_path as path_join of fixture_root and "cwd.wfl" + wait for execute command "wfl" with arguments [child_path, "synthetic"] as outcome + expect outcome["exit_code"] to equal 0 + expect outcome["output"] to contain fixture_root + end test + test "complete outcome contains stdout after exit is known": + store child_path as path_join of fixture_root and "cwd.wfl" + wait for spawn command "wfl" with arguments [child_path, "synthetic"] as child_process + wait for process child_process to complete as child_exit + store final_output as "" + try: + wait for read output from process child_process as captured_output + change final_output to captured_output + when error: + display error_message + end try + expect child_exit to equal 0 + expect final_output to contain "synthetic" + end test + test "asynchronous diagnostics include the real child error": + store child_path as path_join of fixture_root and "error.wfl" + wait for execute command "wfl" with arguments [child_path] as foreground_result + expect foreground_result["error"] to contain "Division by zero" + wait for spawn command "wfl" with arguments [child_path] as child_process + count from 1 to 100: + check if (process child_process is running) is no: + break + end check + wait for 100 milliseconds + end count + wait for read output from process child_process as captured_output + wait for process child_process to complete as child_exit + expect child_exit to equal 1 + expect captured_output to contain "Division by zero" + end test +end describe diff --git a/tests/fixtures/process/cwd.wfl b/tests/fixtures/process/cwd.wfl new file mode 100644 index 00000000..4198b598 --- /dev/null +++ b/tests/fixtures/process/cwd.wfl @@ -0,0 +1,2 @@ +display current_directory +display args[0] diff --git a/tests/fixtures/process/error.wfl b/tests/fixtures/process/error.wfl new file mode 100644 index 00000000..4284bbc7 --- /dev/null +++ b/tests/fixtures/process/error.wfl @@ -0,0 +1,3 @@ +display "synthetic stdout before error" +store invalid_number as 1 divided by 0 +display invalid_number From b2eddd4c630e067583931785f9a1ee437a8bc5c8 Mon Sep 17 00:00:00 2001 From: Brad Byrd Date: Sun, 20 Sep 2026 04:06:26 -0500 Subject: [PATCH 2/6] feat(process): own subprocess completion and isolated execution --- Cargo.lock | 66 +++ Cargo.toml | 3 +- .../subprocess-execution.md | 454 ++++----------- Docs/reference/configuration-reference.md | 14 +- Docs/reference/keyword-reference.md | 2 +- Docs/reference/reserved-keywords.md | 4 +- .../evidence/2026-09-20-process-lifecycle.md | 58 +- .../2026/2026-09-20-owned-process-results.md | 33 ++ TestPrograms/process/.wflcfg | 5 +- TestPrograms/process/failure-cleanup.test.wfl | 59 ++ TestPrograms/process/lifecycle.test.wfl | 251 +++++++- TestPrograms/process/ownership.test.wfl | 74 +++ fuzz/Cargo.lock | 82 +++ src/analyzer/mod.rs | 24 +- src/fixer/source.rs | 9 + src/interpreter/mod.rs | 547 ++++++++++++------ src/interpreter/owned_process.rs | 135 +++++ src/linter/layout.rs | 24 +- src/main.rs | 7 + src/parser/ast.rs | 7 + src/parser/mod.rs | 4 +- src/parser/stmt/actions.rs | 43 +- src/parser/stmt/processes.rs | 77 ++- src/typechecker/mod.rs | 65 ++- tests/fixtures/process/.wflcfg | 6 + .../fixtures/process/assertion-close.test.wfl | 14 + tests/fixtures/process/exit-alias.wfl | 1 + tests/fixtures/process/exit-camel.wfl | 2 + tests/fixtures/process/exit-high.wfl | 1 + tests/fixtures/process/exit.wfl | 9 + tests/fixtures/process/flood.wfl | 4 + tests/fixtures/process/late-write.wfl | 19 + tests/fixtures/process/nested-driver.wfl | 31 + tests/fixtures/process/policy/.wflcfg | 6 + tests/fixtures/process/policy/cwd-policy.wfl | 19 + ...checker_statement_operand_contract_test.rs | 4 + 36 files changed, 1591 insertions(+), 572 deletions(-) create mode 100644 History/dev-diary/2026/2026-09-20-owned-process-results.md create mode 100644 TestPrograms/process/failure-cleanup.test.wfl create mode 100644 TestPrograms/process/ownership.test.wfl create mode 100644 src/interpreter/owned_process.rs create mode 100644 tests/fixtures/process/.wflcfg create mode 100644 tests/fixtures/process/assertion-close.test.wfl create mode 100644 tests/fixtures/process/exit-alias.wfl create mode 100644 tests/fixtures/process/exit-camel.wfl create mode 100644 tests/fixtures/process/exit-high.wfl create mode 100644 tests/fixtures/process/exit.wfl create mode 100644 tests/fixtures/process/flood.wfl create mode 100644 tests/fixtures/process/late-write.wfl create mode 100644 tests/fixtures/process/nested-driver.wfl create mode 100644 tests/fixtures/process/policy/.wflcfg create mode 100644 tests/fixtures/process/policy/cwd-policy.wfl diff --git a/Cargo.lock b/Cargo.lock index 0f6e2f6c..76fc6882 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1024,6 +1024,7 @@ checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", + "futures-executor", "futures-io", "futures-sink", "futures-task", @@ -2293,6 +2294,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "process-wrap" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e3f4237d0e4741eb50bc5584db701f1299c85fa31ff0274dd6445e79dc42d12" +dependencies = [ + "futures", + "indexmap", + "nix", + "tokio", + "windows", +] + [[package]] name = "quinn" version = "0.11.11" @@ -4007,6 +4021,7 @@ dependencies = [ "num-bigint-dig", "once_cell", "pbkdf2", + "process-wrap", "rand 0.10.2", "rcgen", "regex", @@ -4082,6 +4097,27 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -4095,6 +4131,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -4123,6 +4170,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + [[package]] name = "windows-registry" version = "0.6.1" @@ -4186,6 +4243,15 @@ dependencies = [ "windows_x86_64_msvc", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index 2727fa56..af03e4fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,8 @@ regex = "1.13.0" log = "0.4.33" rustyline = "18.0.1" tokio = { version = "1.52.3", features = ["full"] } +process-wrap = { version = "10.0.0", default-features = false, features = ["tokio1", "job-object", "process-group", "kill-on-drop"] } +libc = "0.2.186" reqwest = { version = "0.13.4", features = ["json", "stream"] } encoding_rs = "0.8.35" # sqlx 0.9 split the old `runtime-tokio-rustls` feature into a separate runtime @@ -113,7 +115,6 @@ dhat-ad-hoc = ["dhat"] # if you are doing ad hoc profiling [dev-dependencies] rcgen = "0.14" -libc = "0.2.186" criterion = "0.8" tokio-tungstenite = "0.30" diff --git a/Docs/04-advanced-features/subprocess-execution.md b/Docs/04-advanced-features/subprocess-execution.md index 900d4679..61ad7d29 100644 --- a/Docs/04-advanced-features/subprocess-execution.md +++ b/Docs/04-advanced-features/subprocess-execution.md @@ -1,400 +1,144 @@ # Subprocess Execution -WFL lets you run external commands and programs. Integrate with system tools, build automation scripts, and extend WFL's capabilities. +WFL can launch a program, wait for its result, and close the processes it owns. +Use separate arguments so filenames and text are passed literally. -## Security: opt-in required +## Enable process execution -**By default, all subprocess execution is disabled.** Both -`execute command` and `spawn command` are blocked unless you enable them in -`.wflcfg`: +Process execution is disabled by default. A trusted project's `.wflcfg` can enable it: ```ini -# .wflcfg — required before any execute/spawn will run allow_shell_execution = true shell_execution_mode = sanitized -# Tighter alternative: -# shell_execution_mode = allowlist_only -# allowed_shell_commands = echo, ls, git +kill_on_shutdown = true ``` -- `allow_shell_execution = false` (default) blocks **every** process launch. -- `shell_execution_mode = forbidden` (default) also blocks every process launch - when the master switch is on. -- Policy applies to **both** the shell form and the `with arguments` form. - Passing arguments is safer against injection *after* a program is allowed; - it is not a bypass of the policy. -- `allowlist_only` permits direct execution only. Shell chaining, pipes, - redirects, expansion, and other shell features are blocked even when the - first command is listed. -- Name-only allowlist entries do not authorize explicit paths with the same - basename. Allow an executable path explicitly when a script must use one. -- Avoid allowlisting shells and interpreters such as `sh`, `cmd.exe`, - PowerShell, or Python: their ordinary arguments can execute additional code. +Both `execute command` and `spawn command` obey the same policy. For a restricted +project, use `shell_execution_mode = allowlist_only` and configure +`allowed_shell_commands` with the permitted executable names or exact paths. +Explicit paths require explicit path entries; a matching basename is insufficient. +Allowlisting an interpreter also permits the code passed to that interpreter. +See the [configuration reference](../reference/configuration-reference.md#security-settings). -See [Configuration Reference](../reference/configuration-reference.md#security-settings) -for full option details. +## Launch, wait, read, and close -## Why Subprocess Execution? - -Run external programs from WFL: -- Execute system commands (ls, git, npm) -- Run build tools -- Integrate with external utilities -- Automate system administration -- Call other programming languages - -Shell and resource limits are controlled by `.wflcfg` (`allow_shell_execution`, `shell_execution_mode`, `allowed_shell_commands`, `max_concurrent_processes`, and related keys). Full reference: **[Configuration Reference](../reference/configuration-reference.md)**. - -## Basic Command Execution - -### Execute and Wait - -Run a command and wait for it to complete (requires opt-in config above): - -```wfl -wait for execute command "echo Hello from command line" as result -display "Command executed" -``` - -**Syntax:** -```wfl -wait for execute command "" as -``` - -**Example:** -```wfl -wait for execute command "ls -la" as listing -display "Directory listing complete" -``` - -### Execute Without Storing - -```wfl -wait for execute command "echo Simple execution" -display "Done" -``` - -## Spawning Background Processes - -### Spawn a Process - -Start a process in the background: - -```wfl -wait for spawn command "echo Background task" as process_handle -display "Process spawned" -``` - -**Syntax:** -```wfl -wait for spawn command "" as -``` - -### Wait for Completion - -```wfl -wait for spawn command "echo Task" as proc -display "Process running..." - -wait for process proc to complete as exit_status -display "Process completed with status: " with exit_status -``` - -## Process Control - -### Check Process Status - -```wfl -wait for spawn command "sleep 5" as long_proc - -store is_running as process long_proc is running -check if is_running: - display "Process is still running" -otherwise: - display "Process has completed" -end check -``` - -### Kill a Process - -```wfl -// "sleep" is a Unix command; on Windows use e.g. "timeout /t 5" instead. -wait for spawn command "sleep 5" as proc - -// Wait a bit -wait for 1000 milliseconds - -// Terminate it -kill process proc -display "Process terminated" -``` - -### Capture Output - -```wfl -wait for spawn command "echo Captured output" as proc - -wait for 100 milliseconds - -wait for read output from process proc as output_data -display "Output: " with output_data -``` - -Captured stdout and stderr each retain at most `max_buffer_size_bytes` raw -stream bytes (10 MiB by default) for both `execute command` and -`spawn command`. When a command produces more, WFL continues draining the -stream so the child cannot deadlock, retains only its most recent bytes, and -prints a truncation warning. Malformed UTF-8 replacement can make the returned -WFL text larger than the raw-byte count, but only by a bounded factor. -Foreground commands also observe the run's `timeout_seconds` deadline and -cooperative cancellation through both process execution and pipe draining; WFL -terminates and reaps a child that stalls past either one. A long-lived -`main loop` is exempt from the run-wide deadline, but each foreground command -inside it still receives a fresh `timeout_seconds` window. - -## Executing WFL Files In-Process - -`execute command` starts a separate program. To run another **WFL file** -inside the current program — without spawning a process — use `execute file`: - -```wfl -execute wfl file at "report.wfl" and read output as report_output -display "Captured: " with report_output -``` - -The file runs in a fresh, isolated environment with the full standard -library. With `and read output as`, everything it displays is captured into a -text variable instead of printed. Errors in the executed file are catchable -with `try`. This powers dynamic web pages — see -[Web Servers](web-servers.md#serving-dynamic-wfl-pages) for passing HTTP -request context with `with `. - -## Error Handling - -Always handle subprocess errors: - -```wfl -try: - wait for execute command "nonexistent_command" as result - display "Success" -when error: - display "Command failed - does the command exist?" -end try -``` - -### Handling Exit Codes +This complete example launches WFL from `PATH` and reads its version: ```wfl +wait for spawn command "wfl" with arguments ["--version"] as child try: - wait for spawn command "exit 1" as proc - wait for process proc to complete as exit_code - - check if exit_code is equal to 0: - display "Success" - otherwise: - display "Command failed with code: " with exit_code + wait for process child to complete with timeout 10 and read result as outcome + display outcome["output"] + check if outcome["success"] is no: + display outcome["error"] + exit program with code 1 end check -catch: - display "Error running command" +finally: + close process child end try ``` -## Common Patterns +`spawn command` returns immediately with a process handle. The wait includes +the direct child's exit and the draining of both output streams. On success it +returns the result and releases the handle together: -### Running Git Commands +| Field | Value | +| --- | --- | +| `output` | Retained standard output text | +| `error` | Retained standard error text | +| `exit_code` | Numeric status; `-1` when the operating system reports no numeric status | +| `success` | `yes` when `exit_code` is zero | -```wfl -define action called git_status: - try: - wait for execute command "git status" as command_output - display "Git status executed" - return yes - catch: - display "Git command failed - is this a git repository?" - return no - end try -end action +A nonzero child exit is a result, not an exception. Launch, wait, and capture +failures are catchable runtime errors. `close process` terminates and reaps an +owned child and releases its readers and handle. It is safe after completion, +after a timeout, or more than once, so use it in `finally`. -call git_status -``` +The timeout is a finite number of seconds from one nanosecond through one year; +fractional seconds are supported. It covers process execution and pipe draining. +On timeout WFL closes the child before raising a `Timeout` error. The run's +execution budget and cancellation still apply. Inside a long-lived `main loop`, +a wait also receives a finite `timeout_seconds` window. -### Build Automation +## Choose the working directory -```wfl -display "=== Build Script ===" - -// Clean -display "Cleaning..." -wait for execute command "rm -rf build" - -// Build -display "Building..." -try: - wait for execute command "cargo build --release" as build_result - display "✓ Build succeeded" -catch: - display "✗ Build failed" - exit with code 1 -end try - -// Test -display "Testing..." -try: - wait for execute command "cargo test" as test_result - display "✓ Tests passed" -catch: - display "✗ Tests failed" - exit with code 1 -end try - -display "=== Build Complete ===" -``` - -### System Information +Add `in directory` after the arguments to set the child's directory: ```wfl -// Get current user -wait for execute command "whoami" as username -display "User: " with username - -// Get system info -wait for execute command "uname -a" as system_info -display "System: " with system_info - -// Get current directory -wait for execute command "pwd" as current_dir -display "Directory: " with current_dir +wait for execute command "wfl" with arguments ["--version"] in directory current_directory as outcome +display outcome["output"] ``` -### File Processing Pipeline +The same clause works with `spawn command`. It changes only that launch; the +parent's directory is unchanged. Explicit executable paths are resolved against +the parent's directory before applying the child's directory, preserving the +exact executable authorized by an allowlist. Missing or +inaccessible directories produce a launch error. Parenthesize a complex directory +expression, such as `in directory (path_join of workspace and "tests")`. -```wfl -display "Processing images..." +`execute command` waits immediately and returns the same four result fields. +Both forms accept the existing `using shell` clause after the directory. Shell +execution still obeys configuration; direct arguments do not bypass policy. -wait for store images as list files in "input" with pattern "*.png" +## Own the process lifetime -for each image in images: - store cmd as "convert " with image with " -resize 50% output/" with image +With `kill_on_shutdown = true`, a launch owns a Windows Job Object or a Unix +process group. Closing, timing out, dropping the interpreter, or observing the +direct child's exit terminates remaining processes in that owned group/job. +Linux also sets parent-death signalling before execution, so nested WFL drivers +that create their own groups are closed when their owning driver is killed. +Windows job assignment occurs while the child is suspended, before its code runs. +This is process ownership, not a security sandbox; programs that deliberately +escape a group are outside that group's ownership. - try: - wait for execute command cmd - display "✓ Processed: " with image - catch: - display "✗ Failed: " with image - end try -end for +The historical default `kill_on_shutdown = false` keeps direct-child lifecycle +behavior and does not promise descendant cleanup. Enable ownership in test +runners and in their nested WFL fixtures. Each unconsumed handle counts against +`max_concurrent_processes`, including a completed child. Waiting or closing +releases capacity without discarding another child's result. -display "Image processing complete" -``` - -## Multiple Concurrent Processes - -```wfl -display "Starting multiple processes..." +`process child is running` polls without consuming the result. Existing +`kill process child` remains available and reports an unknown handle as an error; +`close process child` is the convenient idempotent cleanup form. -wait for spawn command "task1.sh" as proc1 -wait for spawn command "task2.sh" as proc2 -wait for spawn command "task3.sh" as proc3 +## Output bounds and older programs -display "All processes started" +Standard output and standard error each retain at most `max_buffer_size_bytes` +raw bytes (10 MiB by default). WFL continuously drains both streams, retains the +most recent bytes, and warns if older bytes are discarded. Text replacement for +malformed UTF-8 may increase the returned character encoding size by a bounded +factor. The limit applies to foreground commands and background processes. -// Wait for all to complete -wait for process proc1 to complete -display "Task 1 complete" +Existing numeric waits keep their result and release behavior: -wait for process proc2 to complete -display "Task 2 complete" - -wait for process proc3 to complete -display "Task 3 complete" - -display "All tasks finished" -``` - -## Security Considerations - -⚠️ **Important:** Subprocess execution can be dangerous. WFL disables it by -default; enable it only when needed, preferably with `allowlist_only`. - -### Policy layers - -1. **Config policy** (`.wflcfg`) — master switch + mode/allowlist, enforced on - every launch (shell and direct-exec). -2. **Program design** — never splice untrusted input into a command string; - pass values with `with arguments` and restrict which programs run. - -### Command Injection - -**Dangerous:** ```wfl -store user_input as "hello" // Imagine this came from an untrusted user: "; rm -rf /" -store cmd as "echo " with user_input -wait for execute command cmd // UNSAFE: user input goes straight into the command! -``` - -**Safer (after opt-in config allows `echo`):** Don't try to filter out -"dangerous" characters — blocklists are always incomplete. Restrict input to -approved values and pass them as arguments so they are never spliced into a -shell command string. - -```wfl -store user_input as "hello" // Untrusted input from a user or request - -// Restrict input to an approved set of values (allowlist) -store allowed_values as ["hello", "status", "version"] -check if allowed_values contains user_input: - // Pass the value as an argument (argv), never concatenated into a command - wait for execute command "echo" with arguments [user_input] -otherwise: - display "Invalid input - value is not on the allowlist" -end check +wait for spawn command "wfl" with arguments ["--version"] as child +wait for process child to complete as exit_status +display exit_status ``` -### Best Practices - -✅ **Leave defaults off** for untrusted or public-facing hosts - -✅ **Prefer `allowlist_only`** when you must enable subprocesses - -✅ **Validate all input** - Never trust user data - -✅ **Pass arguments, don't concatenate** - Avoid shell metacharacters - -✅ **Limit permissions** - Run with minimal privileges - -✅ **Log commands** - Track what's being executed - -❌ **Don't enable `unrestricted` in production** - -❌ **Don't assume `with arguments` bypasses policy** - it does not - -❌ **Don't ignore exit codes** - Check for failures - -## What You've Learned - -In this section, you learned: - -✅ **Executing commands** - `wait for execute command` -✅ **Spawning processes** - `wait for spawn command` -✅ **Process control** - Check status, kill processes -✅ **Capturing output** - `read output from process` -✅ **Error handling** - Try-catch for robust execution -✅ **Common patterns** - Git, builds, system info, pipelines -✅ **Security** - Command injection risks and prevention - -## Next Steps - -Complete your advanced features knowledge: +`wait for read output from process child as text` consumes currently buffered +stdout while a handle is live. A later full-result wait returns stdout remaining +after those reads and retained stderr. Prefer one full-result wait when complete +diagnostics are needed. Output cannot be read after a numeric wait releases the +handle. -**[Interoperability →](interoperability.md)** -Learn how WFL works with other technologies. +## Return a program status -**[Security Guidelines →](../06-best-practices/security-guidelines.md)** -Critical security practices for subprocess execution. +`exit program with code 1` stops the WFL program after unwinding `finally` +blocks. Codes must be whole numbers from 0 through 255; zero means success. +`exit with code 1` is also accepted. Bare `exit`, `exit loop`, and +`exit program` retain their existing meanings. A failing test run still returns +status 1 even if code requested a different status. -**[Best Practices →](../06-best-practices/index.md)** -Write better, safer WFL code. +## Execute a WFL file in the current process ---- +For a fresh WFL environment without an operating-system process, use +`execute wfl file at "report.wfl" and read output as report_output`. +This captures `display` output and supports catchable runtime errors. +See [web servers](web-servers.md#serving-dynamic-wfl-pages) for passing request +context. Use subprocesses when the operating-system working directory, exit +status, or independent process lifetime matters. -**Previous:** [← Containers (OOP)](containers-oop.md) | **Next:** [Interoperability →](interoperability.md) +The executable regression suite is +[`TestPrograms/process/lifecycle.test.wfl`](../../TestPrograms/process/lifecycle.test.wfl). diff --git a/Docs/reference/configuration-reference.md b/Docs/reference/configuration-reference.md index 092c8367..338f215e 100644 --- a/Docs/reference/configuration-reference.md +++ b/Docs/reference/configuration-reference.md @@ -228,9 +228,9 @@ All keys currently loaded from config files, with defaults. | Key | Type | Default | Purpose | |---|---|---|---| -| `max_concurrent_processes` | integer | `100` | Max simultaneous subprocesses | +| `max_concurrent_processes` | integer | `100` | Max owned, unconsumed background process handles | | `max_buffer_size_bytes` | integer | `10485760` (10 MiB) | Max stdout/stderr buffer per process | -| `kill_on_shutdown` | bool | `false` | Kill spawned processes when the script exits | +| `kill_on_shutdown` | bool | `false` | Own process groups/jobs and close remaining children on shutdown or completion | ### Web server @@ -486,7 +486,8 @@ Emits a warning whenever a shell command is executed. #### `max_concurrent_processes` -Maximum number of subprocesses that can run simultaneously. +Maximum number of owned background process handles. Completed children count +until `wait for process` consumes their result or `close process` releases them. - **Type:** Integer - **Default:** `100` @@ -508,7 +509,12 @@ of this raw-byte ceiling. #### `kill_on_shutdown` -Automatically terminates all spawned subprocesses when the WFL script exits. +Own launched processes in Unix groups or Windows Job Objects and terminate +remaining children when the direct child completes, is closed or times out, or +the interpreter shuts down. Linux also uses parent-death signalling for nested +WFL drivers. Enable this option in a test runner and its nested WFL fixtures. +The default preserves historical direct-child behavior without promising tree +cleanup. See [subprocess ownership](../04-advanced-features/subprocess-execution.md#own-the-process-lifetime). - **Type:** Boolean - **Default:** `false` diff --git a/Docs/reference/keyword-reference.md b/Docs/reference/keyword-reference.md index 12a91ed4..9704f5cc 100644 --- a/Docs/reference/keyword-reference.md +++ b/Docs/reference/keyword-reference.md @@ -29,7 +29,7 @@ Quick lookup for all WFL reserved keywords. | `downward` | Count loop direction | ✗ | | `each` | For each loop | ✗ | | `end` | Close block | ✗ | -| `exit` | Exit loops (`exit loop`) or the program (`exit program`) | ✗ | +| `exit` | Exit loops (`exit loop`) or the program (`exit program [with code number]`) | ✗ | | `for` | For loop | ✗ | | `forever` | Infinite loop | ✗ | | `from` | Count loop start | ✗ | diff --git a/Docs/reference/reserved-keywords.md b/Docs/reference/reserved-keywords.md index f7148f90..1bb502b1 100644 --- a/Docs/reference/reserved-keywords.md +++ b/Docs/reference/reserved-keywords.md @@ -574,7 +574,7 @@ Complete reference table of all 181 keywords. | `character` | Other | Pattern | ❌ | `character class` | | `check` | Structural | Control Flow | ❌ | `check if condition` | | `clear` | Other | Operations | ❌ | `clear data` | -| `close` | Other | File I/O | ❌ | `close file` | +| `close` | Other | File I/O / Process | ❌ | `close file` / `close process` | | `comes` | Other | Web/Network | ❌ | `request comes in` | | `command` | Other | Process | ❌ | `execute command` | | `connections` | Other | Web/Network | ❌ | `network connections` | @@ -607,7 +607,7 @@ Complete reference table of all 181 keywords. | `exactly` | Other | Pattern | ❌ | `exactly 5 times` | | `execute` | Other | Process | ❌ | `execute command` | | `exists` | Other | File I/O | ❌ | `file exists` | -| `exit` | Other | Control Flow | ❌ | `exit loop` / `exit program` | +| `exit` | Other | Control Flow | ❌ | `exit loop` / `exit program with code 1` | | `extension` | Contextual | File I/O | ✅ | `file extension` | | `extensions` | Contextual | File I/O | ✅ | `file extensions` | | `extends` | Structural | OOP | ❌ | `container extends` | diff --git a/Engineering/evidence/2026-09-20-process-lifecycle.md b/Engineering/evidence/2026-09-20-process-lifecycle.md index 8c839d8c..9b055e95 100644 --- a/Engineering/evidence/2026-09-20-process-lifecycle.md +++ b/Engineering/evidence/2026-09-20-process-lifecycle.md @@ -33,5 +33,59 @@ idempotent close, reliable kill/reap on errors, and clean explicit program exit codes. Existing subprocess opt-in policy remains authoritative. Tests, fixture generation and assertions are WFL; existing Rust harnesses may discover them. -Green, compatibility, resource, platform, review and final CI evidence remain -pending. This document is not completion evidence. +Red is preserved by commit `93829ff9`. The former output-loss tests remain in +that commit; current acceptance uses the additive full-result API rather than +changing numeric wait semantics. + +## Local Green and review (Windows, 2026-09-20) + +The final release binary runs these WFL suites through the existing +TestPrograms discovery contract: + +- `wfl --test TestPrograms/process/lifecycle.test.wfl`: 16 passed, 0 failed. +- `wfl --test TestPrograms/process/ownership.test.wfl`: 2 passed, 0 failed. +- `wfl --test TestPrograms/process/failure-cleanup.test.wfl`: 2 passed, 0 failed. +- Existing `TestPrograms/subprocess_comprehensive.wfl`: all eight groups + completed successfully, including live-child kill and numeric completion. +- Existing `TestPrograms/exit_program_test.wfl`: exit 0 at the intended stop. + +The split suites keep lifecycle and nested-child checks below the existing +per-program CI timeout. They exercise cwd and literal argv, stdout plus stderr, +bounded output, capacity denial/reuse, sibling result preservation, finite +timeouts, idempotent close, invalid exit/deadline values, program status through +finally, and descendant cleanup after timeout, success, runtime failure, +explicit status, and a failed WFL assertion. Fixtures use readiness markers +before testing descendants and write only beneath `target/test-artifacts`. + +Existing cargo compatibility tests passed: `execution_budget_test` (41), +`subprocess_cleanup_test` (7), `subprocess_security_test` (19), +`subprocess_test` (13), and `typechecker_statement_operand_contract_test` (26): +106 total. The first attempt lacked the separately built release binary in the +legacy helper's expected location; after copying this worktree's own release +build to that location, all passed. No assertions were weakened. + +`cargo check --all-targets --all-features`, +`cargo clippy --all-targets --all-features -- -D warnings`, `cargo fmt --all --check`, +and the separate fuzz workspace's `cargo check --bins --locked` passed locally. +An additional workspace-wide Clippy check found pre-existing unused/dead-code +warnings in LSP test files; those files are outside this change. The repository's +required Clippy command above remains clean. + +Independent source review identified two defects before Green: changing cwd +could re-resolve an authorized relative executable, and formatting a merged +`code statusCode` token could leave its operand stale. Executable identity is +now resolved before applying cwd; the fixer conservatively protects those +operand spellings. WFL exact-path allowlist and fix-then-execute regressions +pass. The reviewer confirmed both source fixes and reported no remaining +blocking lifecycle finding. + +`process-wrap` 10.0.0 uses only Tokio/process-group/job-object/kill-on-drop +features, has MSRV 1.87 (below WFL's 1.94), and is MIT/Apache-2.0 licensed. +Both root and fuzz lockfiles include it without unrelated version changes. + +## Required remote acceptance + +Linux process groups/parent-death signalling cannot be executed on this Windows +host. Existing Blacksmith Linux and Windows integration/Run WFL Programs jobs, +full cargo/workspace gates, and exact-commit CI review remain required before +merge. Local Windows Green does not claim Linux acceptance. diff --git a/History/dev-diary/2026/2026-09-20-owned-process-results.md b/History/dev-diary/2026/2026-09-20-owned-process-results.md new file mode 100644 index 00000000..421d0457 --- /dev/null +++ b/History/dev-diary/2026/2026-09-20-owned-process-results.md @@ -0,0 +1,33 @@ +# Owned process results for WFL test runners + +Scriptorium's WFL-only runner needs disposable working directories, exact child +status and diagnostics, bounded timeouts, and cleanup of nested server fixtures. +The existing process forms could start children, but numeric completion removed +their output handles and asynchronous reads exposed only stdout. The documented +exit-code syntax was also unavailable. + +The Red commit records three valid WFL programs failing on official nightly +26.9.12. The additive API gives execute/spawn an optional `in directory` clause, +adds `with timeout ... and read result as ...` to completion, idempotent +`close process`, and `exit program with code` (including `exit with code`). +Existing numeric waits still consume their handles. Full completion joins both +bounded stream readers and consumes only that child's ownership. Polling and +sibling cleanup preserve unread results; concurrent launches check capacity +under the insertion lock. + +Opting into `kill_on_shutdown` now owns a process group on Unix or a Windows job. +Linux parent-death signalling closes nested WFL ownership chains after a hard +timeout. Normal direct-child exit closes remaining descendants before pipe EOF +is awaited. Windows job assignment occurs before the child is resumed. +`process-wrap` 10.0.0 supplies the platform wrappers with only the Tokio, +job-object, process-group and kill-on-drop features enabled; its Rust 1.87 MSRV +fits WFL's Rust 1.94 requirement and its MIT/Apache-2.0 license is compatible. + +All new scenarios and fixtures are WFL. The existing release TestPrograms +harness discovers `process/lifecycle.test.wfl`, `process/ownership.test.wfl`, +and `process/failure-cleanup.test.wfl` on Linux and Windows. Boundary +coverage includes capacity exhaustion/release, invalid timeouts and exit codes, +literal argv and cwd, output truncation, timeout cleanup, and nested children +under success, runtime failure and explicit program status. See the +[acceptance evidence](../../../Engineering/evidence/2026-09-20-process-lifecycle.md) +for observed results and remaining platform checks. diff --git a/TestPrograms/process/.wflcfg b/TestPrograms/process/.wflcfg index 8e01bc44..63209022 100644 --- a/TestPrograms/process/.wflcfg +++ b/TestPrograms/process/.wflcfg @@ -1,8 +1,9 @@ # Trusted, synthetic subprocess conformance fixtures only. allow_shell_execution = true shell_execution_mode = sanitized -timeout_seconds = 60 -logging_enabled = false +timeout_seconds = 180 +execution_logging = false debug_report_enabled = false kill_on_shutdown = true max_buffer_size_bytes = 1024 +max_concurrent_processes = 2 diff --git a/TestPrograms/process/failure-cleanup.test.wfl b/TestPrograms/process/failure-cleanup.test.wfl new file mode 100644 index 00000000..e1ae42c4 --- /dev/null +++ b/TestPrograms/process/failure-cleanup.test.wfl @@ -0,0 +1,59 @@ +store repo_root as path_dirname of (path_dirname of script_directory) +store fixture_root as path_join of repo_root and "tests/fixtures/process" +store runtime_path as path_join of repo_root and "target/release/wfl" +check if file exists at (runtime_path with ".exe"): + change runtime_path to runtime_path with ".exe" +end check +check if (length of args) is greater than 0: + change runtime_path to args[0] +end check +store target_root as path_join of repo_root and "target" +store artifact_root as path_join of target_root and "test-artifacts" +store output_root as path_join of artifact_root and "process-failure-cleanup" +store fixture_id as generate_uuid +store case_root as path_join of output_root and fixture_id +call makedirs with case_root + +describe "Owned subprocess failure cleanup": + teardown: + call remove_dir with case_root and yes + end teardown + + test "runtime failure and explicit status close owned descendants": + store child_path as path_join of fixture_root and "nested-driver.wfl" + for each driver_mode in ["error", "status"]: + store marker_path as path_join of case_root and (driver_mode with "-grandchild.txt") + store ready_path as path_join of case_root and (driver_mode with "-ready.txt") + wait for spawn command runtime_path with arguments [child_path, runtime_path, marker_path, ready_path, driver_mode] as child_process + try: + wait for process child_process to complete with timeout 15 and read result as outcome + check if driver_mode is "error": + expect outcome["exit_code"] to equal 1 + expect outcome["error"] to contain "Division by zero" + otherwise: + expect outcome["exit_code"] to equal 7 + end check + expect (is_file of ready_path) to equal yes + finally: + close process child_process + end try + open file at (marker_path with ".release") for writing as release_file + wait for write content "release" into release_file + close file release_file + wait for 900 milliseconds + expect (path_exists of marker_path) to equal no + end for + end test + test "a failed WFL assertion still executes child cleanup": + store child_path as path_join of fixture_root and "assertion-close.test.wfl" + store marker_path as path_join of case_root and "assertion-grandchild.txt" + wait for execute command runtime_path with arguments ["--test", child_path, runtime_path, marker_path] as outcome + expect outcome["exit_code"] to equal 1 + expect outcome["output"] to contain "Failed: 1" + open file at (marker_path with ".release") for writing as release_file + wait for write content "release" into release_file + close file release_file + wait for 900 milliseconds + expect (path_exists of marker_path) to equal no + end test +end describe diff --git a/TestPrograms/process/lifecycle.test.wfl b/TestPrograms/process/lifecycle.test.wfl index 736b53ff..37fdf413 100644 --- a/TestPrograms/process/lifecycle.test.wfl +++ b/TestPrograms/process/lifecycle.test.wfl @@ -1,43 +1,232 @@ -// Behavioral Red: existing grammar demonstrates the requirements that need -// additive cwd and full-result completion APIs. Legacy numeric wait will keep -// its existing releasing behavior; the Green suite will use the new API. store repo_root as path_dirname of (path_dirname of script_directory) store fixture_root as path_join of repo_root and "tests/fixtures/process" -describe "Subprocess requirements before the additive lifecycle API": - test "a fixture runs in its own working directory": +store runtime_path as path_join of repo_root and "target/release/wfl" +check if file exists at (runtime_path with ".exe"): + change runtime_path to runtime_path with ".exe" +end check +check if (length of args) is greater than 0: + change runtime_path to args[0] +end check +store target_root as path_join of repo_root and "target" +store artifact_root as path_join of target_root and "test-artifacts" +store output_root as path_join of artifact_root and "process-lifecycle" +store fixture_id as generate_uuid +store case_root as path_join of output_root and fixture_id +call makedirs with case_root + +describe "Owned subprocess lifecycle": + teardown: + call remove_dir with case_root and yes + end teardown + + test "foreground launch sets cwd and preserves argv literally": store child_path as path_join of fixture_root and "cwd.wfl" - wait for execute command "wfl" with arguments [child_path, "synthetic"] as outcome + wait for execute command runtime_path with arguments [child_path, "spaces ; $ literal"] in directory case_root as outcome expect outcome["exit_code"] to equal 0 - expect outcome["output"] to contain fixture_root + expect outcome["output"] to contain case_root + expect outcome["output"] to contain "spaces ; $ literal" + expect current_directory to equal repo_root end test - test "complete outcome contains stdout after exit is known": - store child_path as path_join of fixture_root and "cwd.wfl" - wait for spawn command "wfl" with arguments [child_path, "synthetic"] as child_process - wait for process child_process to complete as child_exit - store final_output as "" + + test "full completion returns joined stdout stderr and failure code": + store child_path as path_join of fixture_root and "error.wfl" + wait for spawn command runtime_path with arguments [child_path] in directory case_root as child_process + try: + wait for process child_process to complete with timeout 5 and read result as outcome + expect outcome["exit_code"] to equal 1 + expect outcome["success"] to equal no + expect outcome["output"] to contain "synthetic stdout before error" + expect outcome["error"] to contain "Division by zero" + finally: + close process child_process + end try + end test + + test "a timeout kills and reaps before a later suite starts": + store child_path as path_join of fixture_root and "late-write.wfl" + store marker_path as path_join of case_root and "timeout-late.txt" + wait for spawn command runtime_path with arguments [child_path, marker_path] as child_process + store saw_timeout as no try: - wait for read output from process child_process as captured_output - change final_output to captured_output + wait for process child_process to complete with timeout 0.05 and read result as outcome when error: - display error_message + change saw_timeout to (error_message contains "timeout") + finally: + close process child_process end try - expect child_exit to equal 0 - expect final_output to contain "synthetic" + expect saw_timeout to equal yes + expect (process child_process is running) to equal no + wait for 900 milliseconds + expect (path_exists of marker_path) to equal no + wait for execute command runtime_path with arguments ["--version"] as next_outcome + expect next_outcome["exit_code"] to equal 0 end test - test "asynchronous diagnostics include the real child error": - store child_path as path_join of fixture_root and "error.wfl" - wait for execute command "wfl" with arguments [child_path] as foreground_result - expect foreground_result["error"] to contain "Division by zero" - wait for spawn command "wfl" with arguments [child_path] as child_process - count from 1 to 100: - check if (process child_process is running) is no: - break - end check - wait for 100 milliseconds + + test "a fast child completes while an unrelated slow child remains owned": + store child_path as path_join of fixture_root and "late-write.wfl" + store marker_path as path_join of case_root and "closed-late.txt" + wait for spawn command runtime_path with arguments [child_path, marker_path] as slow_child + try: + wait for spawn command runtime_path with arguments ["--version"] as fast_child + try: + wait for process fast_child to complete with timeout 5 and read result as outcome + expect outcome["exit_code"] to equal 0 + expect (process slow_child is running) to equal yes + finally: + close process fast_child + end try + finally: + close process slow_child + close process slow_child + end try + wait for 900 milliseconds + expect (path_exists of marker_path) to equal no + end test + + test "sequential completions release process capacity": + count from 1 to 15: + wait for spawn command runtime_path with arguments ["--version"] as child_process + try: + wait for process child_process to complete with timeout 5 and read result as outcome + expect outcome["success"] to equal yes + finally: + close process child_process + end try end count - wait for read output from process child_process as captured_output - wait for process child_process to complete as child_exit - expect child_exit to equal 1 - expect captured_output to contain "Division by zero" + end test + + test "legacy numeric wait still returns the exit code and releases capacity": + count from 1 to 15: + wait for spawn command runtime_path with arguments ["--version"] as child_process + wait for process child_process to complete as child_exit + expect child_exit to equal 0 + end count + end test + + test "bounded output retains the final tail and child cannot deadlock": + store child_path as path_join of fixture_root and "flood.wfl" + wait for spawn command runtime_path with arguments [child_path] as child_process + try: + wait for process child_process to complete with timeout 5 and read result as outcome + expect outcome["success"] to equal yes + expect outcome["output"] to contain "final output tail" + expect (length of outcome["output"]) to be less than 1025 + finally: + close process child_process + end try + end test + + test "program exit status crosses actions and finally without extra output": + store child_path as path_join of fixture_root and "exit.wfl" + wait for execute command runtime_path with arguments [child_path] as outcome + expect outcome["exit_code"] to equal 7 + expect outcome["output"] to contain "cleanup before exit" + expect (outcome["output"] contains "must not run after exit") to equal no + expect outcome["error"] to equal "" + end test + + test "documented exit with code alias is supported": + store child_path as path_join of fixture_root and "exit-alias.wfl" + wait for execute command runtime_path with arguments [child_path] as outcome + expect outcome["exit_code"] to equal 9 + end test + + test "invalid exit codes are catchable and the portable upper bound is retained": + for each invalid_code in [-1, 256, 1.5]: + store rejected as no + try: + exit program with code invalid_code + when error: + change rejected to (error_message contains "whole number from 0 to 255") + end try + expect rejected to equal yes + end for + store child_path as path_join of fixture_root and "exit-high.wfl" + wait for execute command runtime_path with arguments [child_path] as outcome + expect outcome["exit_code"] to equal 255 + expect outcome["success"] to equal no + end test + + test "invalid wait deadlines do not consume the child result": + wait for spawn command runtime_path with arguments ["--version"] as child_process + try: + for each invalid_timeout in [0, -1, 31536001]: + store rejected as no + try: + wait for process child_process to complete with timeout invalid_timeout and read result as outcome + when error: + change rejected to (error_message contains "finite positive number") + end try + expect rejected to equal yes + end for + wait for process child_process to complete with timeout 5 and read result as outcome + expect outcome["success"] to equal yes + finally: + close process child_process + end try + end test + + test "a missing working directory fails without changing the parent directory": + store missing_directory as path_join of case_root and "absent" + store rejected as no + try: + wait for spawn command runtime_path with arguments ["--version"] in directory missing_directory as child_process + when error: + change rejected to (error_message contains "Failed to spawn") + end try + expect rejected to equal yes + expect current_directory to equal repo_root + end test + + test "capacity denial and sibling close preserve a completed result": + wait for spawn command runtime_path with arguments ["--version"] as first_child + try: + wait for spawn command runtime_path with arguments ["--version"] as second_child + try: + store rejected as no + try: + wait for spawn command runtime_path with arguments ["--version"] as denied_child + when error: + change rejected to (error_message contains "Process limit reached") + end try + expect rejected to equal yes + close process first_child + wait for process second_child to complete with timeout 5 and read result as outcome + expect outcome["output"] to contain "WFL" + finally: + close process second_child + end try + finally: + close process first_child + end try + end test + + test "unknown process close is idempotent but kill retains its diagnostic": + close process "not-a-process" + store failed_kill as no + try: + kill process "not-a-process" + when error: + change failed_kill to yes + end try + expect failed_kill to equal yes + end test + + test "changing cwd retains the exact allowlisted executable": + store policy_root as path_join of fixture_root and "policy" + store child_path as path_join of policy_root and "cwd-policy.wfl" + wait for execute command runtime_path with arguments [child_path, case_root] as outcome + expect outcome["exit_code"] to equal 0 + expect outcome["output"] to contain "authorized executable retained across cwd" + end test + + test "formatting preserves a contextual exit status variable": + store source_path as path_join of fixture_root and "exit-camel.wfl" + store fixed_path as path_join of case_root and "fixed-exit.wfl" + call copy_file with source_path and fixed_path + wait for execute command runtime_path with arguments ["--lint", "--fix", "--in-place", fixed_path] as fixed + expect fixed["exit_code"] to equal 0 + wait for execute command runtime_path with arguments [fixed_path] as outcome + expect outcome["exit_code"] to equal 7 end test end describe diff --git a/TestPrograms/process/ownership.test.wfl b/TestPrograms/process/ownership.test.wfl new file mode 100644 index 00000000..96c0ad74 --- /dev/null +++ b/TestPrograms/process/ownership.test.wfl @@ -0,0 +1,74 @@ +store repo_root as path_dirname of (path_dirname of script_directory) +store fixture_root as path_join of repo_root and "tests/fixtures/process" +store runtime_path as path_join of repo_root and "target/release/wfl" +check if file exists at (runtime_path with ".exe"): + change runtime_path to runtime_path with ".exe" +end check +check if (length of args) is greater than 0: + change runtime_path to args[0] +end check +store target_root as path_join of repo_root and "target" +store artifact_root as path_join of target_root and "test-artifacts" +store output_root as path_join of artifact_root and "process-ownership" +store fixture_id as generate_uuid +store case_root as path_join of output_root and fixture_id +call makedirs with case_root + +describe "Owned subprocess descendants": + teardown: + call remove_dir with case_root and yes + end teardown + + test "hard timeout closes an integration driver and its grandchild": + store child_path as path_join of fixture_root and "nested-driver.wfl" + store marker_path as path_join of case_root and "grandchild-late.txt" + store ready_path as path_join of case_root and "driver-ready.txt" + wait for spawn command runtime_path with arguments [child_path, runtime_path, marker_path, ready_path, "wait"] as child_process + store saw_timeout as no + try: + count from 1 to 300: + check if file exists at ready_path: + break + end check + check if (process child_process is running) is no: + break + end check + wait for 50 milliseconds + end count + expect (is_file of ready_path) to equal yes + try: + wait for process child_process to complete with timeout 0.05 and read result as outcome + when error: + change saw_timeout to (error_message contains "timeout") + end try + finally: + close process child_process + end try + expect saw_timeout to equal yes + open file at (marker_path with ".release") for writing as release_file + wait for write content "release" into release_file + close file release_file + wait for 900 milliseconds + expect (path_exists of marker_path) to equal no + end test + + test "successful driver exit also releases its owned grandchild": + store child_path as path_join of fixture_root and "nested-driver.wfl" + store marker_path as path_join of case_root and "exited-grandchild-late.txt" + store ready_path as path_join of case_root and "exiting-driver-ready.txt" + wait for spawn command runtime_path with arguments [child_path, runtime_path, marker_path, ready_path, "exit"] as child_process + try: + wait for process child_process to complete with timeout 15 and read result as outcome + expect outcome["exit_code"] to equal 0 + expect (is_file of ready_path) to equal yes + finally: + close process child_process + end try + open file at (marker_path with ".release") for writing as release_file + wait for write content "release" into release_file + close file release_file + wait for 900 milliseconds + expect (path_exists of marker_path) to equal no + end test + +end describe diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 3c516e74..46277e58 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -609,6 +609,21 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -682,6 +697,7 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1671,6 +1687,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "process-wrap" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e3f4237d0e4741eb50bc5584db701f1299c85fa31ff0274dd6445e79dc42d12" +dependencies = [ + "futures", + "indexmap", + "nix", + "tokio", + "windows", +] + [[package]] name = "quinn" version = "0.11.11" @@ -3207,11 +3236,13 @@ dependencies = [ "hkdf 0.12.4", "hmac 0.12.1", "hyper 1.10.1", + "libc", "log", "logos", "num-bigint-dig", "once_cell", "pbkdf2", + "process-wrap", "rand 0.10.2", "regex", "reqwest", @@ -3257,6 +3288,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -3270,6 +3322,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -3298,6 +3361,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + [[package]] name = "windows-registry" version = "0.6.1" @@ -3361,6 +3434,15 @@ dependencies = [ "windows_x86_64_msvc", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" diff --git a/src/analyzer/mod.rs b/src/analyzer/mod.rs index e25fdadd..d0b5b097 100644 --- a/src/analyzer/mod.rs +++ b/src/analyzer/mod.rs @@ -2978,12 +2978,16 @@ impl Analyzer { Statement::ExecuteCommandStatement { command, arguments, + directory, variable_name, use_shell: _, line, column, } => { self.analyze_expression(command); + if let Some(directory) = directory { + self.analyze_expression(directory); + } if let Some(args) = arguments { self.analyze_expression(args); } @@ -3004,6 +3008,11 @@ impl Analyzer { } } + Statement::ExitStatement { + code: Some(code), .. + } => self.analyze_expression(code), + Statement::ExitStatement { code: None, .. } => {} + Statement::ExecuteFileStatement { path, request, @@ -3035,12 +3044,16 @@ impl Analyzer { Statement::SpawnProcessStatement { command, arguments, + directory, variable_name, use_shell: _, line, column, } => { self.analyze_expression(command); + if let Some(directory) = directory { + self.analyze_expression(directory); + } if let Some(args) = arguments { self.analyze_expression(args); } @@ -3086,16 +3099,25 @@ impl Analyzer { Statement::WaitForProcessStatement { process_id, variable_name, + timeout, + full_result, line, column, } => { self.analyze_expression(process_id); + if let Some(timeout) = timeout { + self.analyze_expression(timeout); + } if let Some(var_name) = variable_name { let symbol = Symbol { name: var_name.clone(), kind: SymbolKind::Variable { mutable: true }, - symbol_type: Some(Type::Number), // Exit code + symbol_type: Some(if *full_result { + Type::Map(Box::new(Type::Text), Box::new(Type::Any)) + } else { + Type::Number + }), line: *line, column: *column, }; diff --git a/src/fixer/source.rs b/src/fixer/source.rs index 99a88b5d..7070eea7 100644 --- a/src/fixer/source.rs +++ b/src/fixer/source.rs @@ -418,6 +418,15 @@ fn rename_locals( // A local spelling can also occur as an external property or method name. // Renaming all occurrences would silently change those public APIs. for pair in tokens.windows(2) { + // `exit ... with code statusCode` merges its contextual marker and + // operand into one token. Whole-token renaming cannot safely update + // that reference, so preserve the local's spelling throughout. + if matches!(pair[0].token, Token::KeywordWith) + && let Token::Identifier(name) = &pair[1].token + && let Some(operand) = name.strip_prefix("code ") + { + protected.insert(operand); + } if matches!(pair[0].token, Token::Dot | Token::Colon) && let Token::Identifier(name) = &pair[1].token { diff --git a/src/interpreter/mod.rs b/src/interpreter/mod.rs index 01fb4368..4480e99b 100644 --- a/src/interpreter/mod.rs +++ b/src/interpreter/mod.rs @@ -15,6 +15,7 @@ mod memory_tests; mod op_refactor_error_tests; #[cfg(test)] mod op_refactor_tests; +mod owned_process; #[cfg(test)] mod tests; mod tls; @@ -1596,6 +1597,7 @@ use tokio::sync::Mutex; /// constructors honor the caller's `max_call_depth` verbatim precisely so the /// CLI (and any embedder that has arranged the stack) can raise it. pub struct Interpreter { + program_exit_code: Cell, global_env: Rc>, current_count: RefCell>, in_count_loop: RefCell, @@ -1845,7 +1847,7 @@ impl Drop for ArmedEnforcementGuard { // Process handle for managing subprocess state #[allow(dead_code)] pub struct ProcessHandle { - child: tokio::process::Child, + child: owned_process::OwnedChild, command: String, args: Vec, started_at: Instant, @@ -1853,6 +1855,57 @@ pub struct ProcessHandle { exit_code: Option, stdout_buffer: Arc>, stderr_buffer: Arc>, + stdout_task: Option>>, + stderr_task: Option>>, +} + +impl Drop for ProcessHandle { + fn drop(&mut self) { + if let Some(task) = self.stdout_task.take() { + task.abort(); + } + if let Some(task) = self.stderr_task.take() { + task.abort(); + } + } +} + +async fn capture_background_process_stream( + mut stream: R, + buffer: Arc>, +) -> Result<(), String> +where + R: tokio::io::AsyncRead + Unpin, +{ + use tokio::io::AsyncReadExt; + let mut bytes = [0u8; 4096]; + let mut warned = false; + loop { + let count = stream + .read(&mut bytes) + .await + .map_err(|error| format!("Failed to read subprocess output: {error}"))?; + if count == 0 { + return Ok(()); + } + let mut buffer = buffer.lock().await; + buffer.push(&bytes[..count]); + if buffer.stats().bytes_dropped > 0 && !warned { + eprintln!( + "Warning: subprocess output exceeded max_buffer_size_bytes; oldest bytes were discarded." + ); + warned = true; + } + } +} + +fn process_result_value(output: String, error: String, exit_code: i32) -> Value { + Value::Object(Rc::new(RefCell::new(HashMap::from([ + ("output".to_string(), Value::Text(Arc::from(output))), + ("error".to_string(), Value::Text(Arc::from(error))), + ("exit_code".to_string(), Value::Number(exit_code as f64)), + ("success".to_string(), Value::Bool(exit_code == 0)), + ])))) } /// Failure from a foreground `execute command`. Budget breaches stay typed so @@ -2024,14 +2077,19 @@ async fn foreground_command_interrupt( /// Kill and reap the direct child unless it has already completed. A second /// status check handles the normal race where it exits between inspection and /// the kill request. -async fn terminate_foreground_child(child: &mut tokio::process::Child) -> Result<(), String> { +async fn terminate_foreground_child(child: &mut owned_process::OwnedChild) -> Result<(), String> { match child.try_wait() { Ok(Some(_)) => return Ok(()), Ok(None) => {} Err(error) => return Err(format!("failed to inspect subprocess: {error}")), } - match child.kill().await { + match async { + child.start_kill()?; + child.wait().await.map(|_| ()) + } + .await + { Ok(()) => Ok(()), Err(kill_error) => match child.try_wait() { Ok(Some(_)) => Ok(()), @@ -4368,6 +4426,19 @@ impl IoClient { use_shell: bool, line: usize, column: usize, + ) -> Result<(String, String, i32), ExecuteCommandError> { + self.execute_command_in_directory(command, args, use_shell, None, line, column) + .await + } + + async fn execute_command_in_directory( + &self, + command: &str, + args: &[&str], + use_shell: bool, + directory: Option<&str>, + line: usize, + column: usize, ) -> Result<(String, String, i32), ExecuteCommandError> { use crate::interpreter::command_sanitizer::CommandSanitizer; use tokio::process::Command; @@ -4407,33 +4478,40 @@ impl IoClient { ) }; + let program = + owned_process::executable_for_directory(&program, directory).map_err(|error| { + ExecuteCommandError::Other(format!("Failed to resolve executable: {error}")) + })?; let mut cmd = Command::new(program); cmd.args(parsed_args); cmd }; + if let Some(directory) = directory { + cmd.current_dir(directory); + } + // `Command::output` accumulates both streams into unbounded Vecs and // cannot observe WFL's cooperative cancellation while the child is // stalled. Pipe and drain both streams concurrently under the existing // per-stream buffer ceiling instead. `kill_on_drop` is a final safety // net if this future itself is abandoned by its caller. - let mut child = cmd - .stdin(std::process::Stdio::null()) + cmd.stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .kill_on_drop(true) - .spawn() - .map_err(|e| { - ExecuteCommandError::Other(format!( - "Failed to execute command '{}': {}", - command, e - )) - })?; + .stderr(std::process::Stdio::piped()); + let mut child = + owned_process::spawn(cmd, self.config.subprocess_config.kill_on_shutdown, true) + .map_err(|e| { + ExecuteCommandError::Other(format!( + "Failed to execute command '{}': {}", + command, e + )) + })?; - let stdout_pipe = child.stdout.take().ok_or_else(|| { + let stdout_pipe = child.stdout().take().ok_or_else(|| { ExecuteCommandError::Other("Failed to capture command stdout".to_string()) })?; - let stderr_pipe = child.stderr.take().ok_or_else(|| { + let stderr_pipe = child.stderr().take().ok_or_else(|| { ExecuteCommandError::Other("Failed to capture command stderr".to_string()) })?; let buffer_size = self.config.subprocess_config.max_buffer_size_bytes; @@ -4542,27 +4620,23 @@ impl IoClient { use_shell: bool, line: usize, column: usize, + ) -> Result { + self.spawn_process_in_directory(command, args, use_shell, None, line, column) + .await + } + + async fn spawn_process_in_directory( + &self, + command: &str, + args: &[&str], + use_shell: bool, + directory: Option<&str>, + line: usize, + column: usize, ) -> Result { use crate::interpreter::command_sanitizer::CommandSanitizer; - use tokio::io::AsyncReadExt; use tokio::process::Command; - // Clean up completed processes before spawning new one - // self.cleanup_completed_processes().await; - - // Check process limit - { - let handles = self.process_handles.lock().await; - if handles.len() >= self.config.subprocess_config.max_concurrent_processes { - return Err(format!( - "Process limit reached: {} processes currently running (max: {}). \ - Consider waiting for processes to complete or increasing max_concurrent_processes in .wflcfg", - handles.len(), - self.config.subprocess_config.max_concurrent_processes - )); - } - } - let needs_shell = self.authorize_subprocess(command, args, use_shell, line, column)?; // Build the command @@ -4592,17 +4666,19 @@ impl IoClient { ) }; + let program = owned_process::executable_for_directory(&program, directory) + .map_err(|error| format!("Failed to resolve executable: {error}"))?; let mut cmd = Command::new(program); cmd.args(parsed_args); cmd }; - let mut child = cmd + if let Some(directory) = directory { + cmd.current_dir(directory); + } + cmd.stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .spawn() - .map_err(|e| format!("Failed to spawn process '{}': {}", command, e))?; - + .stderr(std::process::Stdio::piped()); // Generate process ID let process_id = { let mut next_id = self.next_process_id.lock().await; @@ -4611,6 +4687,23 @@ impl IoClient { id }; + // Reserve/check ownership and insert under one registry lock. Two + // concurrent launches must not both observe the final free slot. + let mut handles = self.process_handles.lock().await; + if handles.len() >= self.config.subprocess_config.max_concurrent_processes { + return Err(format!( + "Process limit reached: {} owned processes (max: {}). Wait for completion or close a process before spawning another.", + handles.len(), + self.config.subprocess_config.max_concurrent_processes + )); + } + let mut child = owned_process::spawn( + cmd, + self.config.subprocess_config.kill_on_shutdown, + self.config.subprocess_config.kill_on_shutdown, + ) + .map_err(|e| format!("Failed to spawn process '{}': {}", command, e))?; + // Create buffers for stdout and stderr with configurable size let buffer_size = self.config.subprocess_config.max_buffer_size_bytes; let stdout_buffer = Arc::new(tokio::sync::Mutex::new(bounded_buffer::BoundedBuffer::new( @@ -4621,66 +4714,21 @@ impl IoClient { ))); // Spawn background tasks to collect stdout and stderr - let stdout = child.stdout.take(); - let stderr = child.stderr.take(); - - if let Some(mut stdout) = stdout { - let buffer = Arc::clone(&stdout_buffer); - let cmd = command.to_string(); - tokio::spawn(async move { - let mut buf = vec![0u8; 4096]; - let mut warning_shown = false; - loop { - match stdout.read(&mut buf).await { - Ok(0) => break, // EOF - Ok(n) => { - let mut locked_buffer = buffer.lock().await; - locked_buffer.push(&buf[..n]); - - // Warn once if data is being dropped - if locked_buffer.stats().bytes_dropped > 0 && !warning_shown { - eprintln!( - "⚠️ WARNING: Process '{}' stdout buffer overflow. \ - Data is being dropped. Consider reading output more frequently.", - cmd - ); - warning_shown = true; - } - } - Err(_) => break, - } - } - }); - } + let stdout = child.stdout().take(); + let stderr = child.stderr().take(); - if let Some(mut stderr) = stderr { - let buffer = Arc::clone(&stderr_buffer); - let cmd = command.to_string(); - tokio::spawn(async move { - let mut buf = vec![0u8; 4096]; - let mut warning_shown = false; - loop { - match stderr.read(&mut buf).await { - Ok(0) => break, // EOF - Ok(n) => { - let mut locked_buffer = buffer.lock().await; - locked_buffer.push(&buf[..n]); - - // Warn once if data is being dropped - if locked_buffer.stats().bytes_dropped > 0 && !warning_shown { - eprintln!( - "⚠️ WARNING: Process '{}' stderr buffer overflow. \ - Data is being dropped. Consider reading output more frequently.", - cmd - ); - warning_shown = true; - } - } - Err(_) => break, - } - } - }); - } + let stdout_task = stdout.map(|stream| { + tokio::spawn(capture_background_process_stream( + stream, + Arc::clone(&stdout_buffer), + )) + }); + let stderr_task = stderr.map(|stream| { + tokio::spawn(capture_background_process_stream( + stream, + Arc::clone(&stderr_buffer), + )) + }); // Store process handle let handle = ProcessHandle { @@ -4692,12 +4740,11 @@ impl IoClient { exit_code: None, stdout_buffer, stderr_buffer, + stdout_task, + stderr_task, }; - self.process_handles - .lock() - .await - .insert(process_id.clone(), handle); + handles.insert(process_id.clone(), handle); Ok(process_id) } @@ -4742,42 +4789,132 @@ impl IoClient { /// Kill a running process #[allow(dead_code)] async fn kill_process(&self, process_id: &str) -> Result<(), String> { - { - let mut handles = self.process_handles.lock().await; - let handle = handles - .get_mut(process_id) - .ok_or_else(|| format!("Invalid process ID: {}", process_id))?; - - handle - .child - .kill() - .await - .map_err(|e| format!("Failed to kill process: {}", e))?; - } - - // Clean up killed and other completed processes - self.cleanup_completed_processes().await; + self.close_process(process_id, false).await + } - Ok(()) + async fn close_process(&self, process_id: &str, idempotent: bool) -> Result<(), String> { + let handle = self.process_handles.lock().await.remove(process_id); + match handle { + Some(mut handle) => terminate_foreground_child(&mut handle.child).await, + None if idempotent => Ok(()), + None => Err(format!("Invalid process ID: {process_id}")), + } } /// Wait for a process to complete and return its exit code #[allow(dead_code)] async fn wait_for_process(&self, process_id: &str) -> Result { - let mut handle = { - let mut handles = self.process_handles.lock().await; - handles - .remove(process_id) - .ok_or_else(|| format!("Invalid process ID: {}", process_id))? - }; - - let status = handle - .child - .wait() + self.wait_for_process_result(process_id, None) .await - .map_err(|e| format!("Failed to wait for process: {}", e))?; + .map(|(_, _, code)| code) + .map_err(|error| error.to_string()) + } - Ok(status.code().unwrap_or(-1)) + async fn wait_for_process_result( + &self, + process_id: &str, + timeout: Option, + ) -> Result<(String, String, i32), ExecuteCommandError> { + let budget = ExecutionBudget::current_or_default(); + let configured_timeout = Duration::from_secs(self.config.timeout_seconds.max(1)); + let deadline = foreground_command_deadline(&budget, configured_timeout)?; + let explicit_deadline = timeout.and_then(|duration| Instant::now().checked_add(duration)); + let operation = async { + // Poll without holding the registry across an await. Another + // handler can still inspect/close this child or wait for a sibling. + loop { + let mut handles = self.process_handles.lock().await; + let handle = handles.get_mut(process_id).ok_or_else(|| { + ExecuteCommandError::Other(format!( + "Invalid or closed process ID: {process_id}" + )) + })?; + match handle.child.try_wait() { + Ok(Some(status)) => { + handle.exit_code = Some(status.code().unwrap_or(-1)); + break; + } + Ok(None) => {} + Err(error) => { + return Err(ExecuteCommandError::Other(format!( + "Failed to wait for process: {error}" + ))); + } + } + drop(handles); + tokio::time::sleep(SUBPROCESS_BUDGET_POLL_INTERVAL).await; + } + // Keep the record until both readers reach EOF. They can finish + // after the direct child, and a descendant can withhold pipe EOF. + loop { + let mut handles = self.process_handles.lock().await; + let handle = handles.get_mut(process_id).ok_or_else(|| { + ExecuteCommandError::Other(format!( + "Invalid or closed process ID: {process_id}" + )) + })?; + let stdout_done = handle + .stdout_task + .as_ref() + .is_none_or(|task| task.is_finished()); + let stderr_done = handle + .stderr_task + .as_ref() + .is_none_or(|task| task.is_finished()); + if stdout_done && stderr_done { + let mut handle = handles.remove(process_id).expect("process checked above"); + drop(handles); + if let Some(task) = handle.stdout_task.take() { + task.await + .map_err(|error| { + ExecuteCommandError::Other(format!( + "Failed to collect subprocess stdout: {error}" + )) + })? + .map_err(ExecuteCommandError::Other)?; + } + if let Some(task) = handle.stderr_task.take() { + task.await + .map_err(|error| { + ExecuteCommandError::Other(format!( + "Failed to collect subprocess stderr: {error}" + )) + })? + .map_err(ExecuteCommandError::Other)?; + } + let output = + String::from_utf8_lossy(&handle.stdout_buffer.lock().await.read_all()) + .to_string(); + let error = + String::from_utf8_lossy(&handle.stderr_buffer.lock().await.read_all()) + .to_string(); + return Ok((output, error, handle.exit_code.unwrap_or(-1))); + } + drop(handles); + tokio::time::sleep(SUBPROCESS_BUDGET_POLL_INTERVAL).await; + } + }; + let interrupt = async { + if let Some(explicit_deadline) = explicit_deadline { + tokio::select! { + error = foreground_command_interrupt(&budget, deadline) => error, + _ = tokio::time::sleep_until(tokio::time::Instant::from_std(explicit_deadline)) => ExecuteCommandError::Timeout { seconds: timeout.unwrap_or_default().as_secs() }, + } + } else { + foreground_command_interrupt(&budget, deadline).await + } + }; + let result = tokio::select! { + biased; + result = operation => result, + error = interrupt => Err(error), + }; + if result.is_err() { + self.close_process(process_id, true) + .await + .map_err(ExecuteCommandError::Other)?; + } + result } /// Check if a process is still running @@ -4789,7 +4926,7 @@ impl IoClient { } else { false } - // Note: Cleanup happens in spawn_process and kill_process + // Completion is consumed by wait or close; polling never discards it. } } @@ -4909,6 +5046,7 @@ impl Interpreter { } Interpreter { + program_exit_code: Cell::new(0), global_env, current_count: RefCell::new(None), in_count_loop: RefCell::new(false), @@ -4999,6 +5137,11 @@ impl Interpreter { self.test_results.borrow().clone() } + /// Explicit program exit status, separate from language/runtime failures. + pub fn program_exit_code(&self) -> i32 { + self.program_exit_code.get() + } + /// Extract variables from the environment for module analyzer /// Returns a HashMap of variable names to (inferred type, is_mutable) /// Snapshot of what an included/loaded file can see from its enclosing @@ -6689,6 +6832,7 @@ impl Interpreter { } pub async fn interpret(&mut self, program: &Program) -> Result> { + self.program_exit_code.set(0); // Scope this run's budget as the TASK-local current budget, so leaf // helpers with no budget parameter (the stdlib pattern builtins in // particular) match under the run's configured ceilings and shared @@ -8052,6 +8196,7 @@ impl Interpreter { Statement::ExitStatement { scope, + code, line, column, } => { @@ -8065,7 +8210,30 @@ impl Interpreter { // expression evaluation too — neither of which carries a // control-flow channel — and is turned back into a // successful finish at the top of the run. - ExitScope::Program => Err(RuntimeError::exit_program(*line, *column)), + ExitScope::Program => { + let code = + if let Some(code) = code { + match self.evaluate_expression(code, Rc::clone(&env)).await? { + Value::Number(number) + if number.is_finite() + && number.fract() == 0.0 + && (0.0..=255.0).contains(&number) => + { + number as i32 + } + _ => return Err(RuntimeError::new( + "Program exit code must be a whole number from 0 to 255" + .to_string(), + *line, + *column, + )), + } + } else { + 0 + }; + self.program_exit_code.set(code); + Err(RuntimeError::exit_program(*line, *column)) + } } } @@ -12925,6 +13093,7 @@ impl Interpreter { Statement::ExecuteCommandStatement { command, arguments, + directory, variable_name, use_shell, line, @@ -12973,11 +13142,32 @@ impl Interpreter { Vec::new() }; + let directory_value = if let Some(directory) = directory { + match self.evaluate_expression(directory, Rc::clone(&env)).await? { + Value::Text(value) => Some(value.to_string()), + _ => { + return Err(RuntimeError::new( + "Process working directory must be text".to_string(), + *line, + *column, + )); + } + } + } else { + None + }; // Execute command let args_refs: Vec<&str> = args_vec.iter().map(|s| s.as_str()).collect(); let (stdout, stderr, exit_code) = self .io_client - .execute_command(cmd_str, &args_refs, *use_shell, *line, *column) + .execute_command_in_directory( + cmd_str, + &args_refs, + *use_shell, + directory_value.as_deref(), + *line, + *column, + ) .await .map_err(|e| match e { ExecuteCommandError::Budget(exceeded) => { @@ -13007,16 +13197,7 @@ impl Interpreter { })?; // Build result object - let mut result_map = HashMap::new(); - result_map.insert( - "output".to_string(), - Value::Text(Arc::from(stdout.as_str())), - ); - result_map.insert("error".to_string(), Value::Text(Arc::from(stderr.as_str()))); - result_map.insert("exit_code".to_string(), Value::Number(exit_code as f64)); - result_map.insert("success".to_string(), Value::Bool(exit_code == 0)); - - let result_obj = Value::Object(Rc::new(RefCell::new(result_map))); + let result_obj = process_result_value(stdout, stderr, exit_code); // Store result if variable name provided if let Some(var_name) = variable_name { @@ -13051,6 +13232,7 @@ impl Interpreter { Statement::SpawnProcessStatement { command, arguments, + directory, variable_name, use_shell, line, @@ -13099,11 +13281,32 @@ impl Interpreter { Vec::new() }; + let directory_value = if let Some(directory) = directory { + match self.evaluate_expression(directory, Rc::clone(&env)).await? { + Value::Text(value) => Some(value.to_string()), + _ => { + return Err(RuntimeError::new( + "Process working directory must be text".to_string(), + *line, + *column, + )); + } + } + } else { + None + }; // Spawn process let args_refs: Vec<&str> = args_vec.iter().map(|s| s.as_str()).collect(); let process_id = self .io_client - .spawn_process(cmd_str, &args_refs, *use_shell, *line, *column) + .spawn_process_in_directory( + cmd_str, + &args_refs, + *use_shell, + directory_value.as_deref(), + *line, + *column, + ) .await .map_err(|e| { let kind = if e.contains("program not found") @@ -13168,6 +13371,7 @@ impl Interpreter { } Statement::KillProcessStatement { process_id, + idempotent, line, column, } => { @@ -13187,20 +13391,25 @@ impl Interpreter { }; // Kill process - self.io_client.kill_process(proc_id).await.map_err(|e| { - let kind = if e.contains("Invalid process ID") { - ErrorKind::ProcessNotFound - } else { - ErrorKind::ProcessKillFailed - }; - RuntimeError::with_kind(e, *line, *column, kind) - })?; + self.io_client + .close_process(proc_id, *idempotent) + .await + .map_err(|e| { + let kind = if e.contains("Invalid process ID") { + ErrorKind::ProcessNotFound + } else { + ErrorKind::ProcessKillFailed + }; + RuntimeError::with_kind(e, *line, *column, kind) + })?; Ok((Value::Null, ControlFlow::None)) } Statement::WaitForProcessStatement { process_id, variable_name, + timeout, + full_result, line, column, } => { @@ -13219,24 +13428,38 @@ impl Interpreter { } }; - // Wait for process to complete - let exit_code = self + let timeout_value = if let Some(timeout) = timeout { + match self.evaluate_expression(timeout, Rc::clone(&env)).await? { + Value::Number(seconds) if seconds.is_finite() && (0.000000001..=365.0 * 24.0 * 3600.0).contains(&seconds) => Some(Duration::from_secs_f64(seconds)), + _ => return Err(RuntimeError::new("Process timeout must be a finite positive number of seconds, at most one year".to_string(), *line, *column)), + } + } else { + None + }; + // Completion joins both capture tasks before releasing ownership. + let (output, error, exit_code) = self .io_client - .wait_for_process(proc_id) + .wait_for_process_result(proc_id, timeout_value) .await - .map_err(|e| { - let kind = if e.contains("Invalid process ID") { - ErrorKind::ProcessNotFound - } else { - ErrorKind::General - }; - RuntimeError::with_kind(e, *line, *column, kind) + .map_err(|error| match error { + ExecuteCommandError::Budget(exceeded) => self.budget_error(exceeded, *line, *column), + ExecuteCommandError::Timeout { seconds } => { + let seconds = timeout_value.map_or(seconds as f64, |timeout| timeout.as_secs_f64()); + RuntimeError::with_kind(format!("Subprocess wait exceeded timeout ({seconds}s); the owned process was closed"), *line, *column, ErrorKind::Timeout) + }, + ExecuteCommandError::Other(message) if message.starts_with("Invalid or closed process ID:") => RuntimeError::with_kind(message, *line, *column, ErrorKind::ProcessNotFound), + ExecuteCommandError::Other(message) => RuntimeError::new(message, *line, *column), })?; // Store exit code in variable if provided if let Some(var_name) = variable_name { + let result_value = if *full_result { + process_result_value(output, error, exit_code) + } else { + Value::Number(exit_code as f64) + }; env.borrow_mut() - .define_direct(var_name, Value::Number(exit_code as f64)) + .define_direct(var_name, result_value) .map_err(|e| RuntimeError::new(e, *line, *column))?; } diff --git a/src/interpreter/owned_process.rs b/src/interpreter/owned_process.rs new file mode 100644 index 00000000..3f8e7cb0 --- /dev/null +++ b/src/interpreter/owned_process.rs @@ -0,0 +1,135 @@ +//! Platform ownership for subprocess trees. Opted-in ownership is configured +//! before the child can execute, including Windows suspended job assignment. +use process_wrap::tokio::{ChildWrapper, CommandWrap, KillOnDrop}; +use std::io; +use std::ops::{Deref, DerefMut}; + +pub(super) fn executable_for_directory( + program: &str, + directory: Option<&str>, +) -> io::Result { + // Authorization resolves explicit executable paths against the parent's + // cwd. Freeze that identity before Command::current_dir can change how a + // relative path is resolved by the operating system. + if directory.is_some() + && (program.contains('/') + || program.contains('\\') + || program.as_bytes().get(1) == Some(&b':')) + { + std::fs::canonicalize(program) + } else { + Ok(program.into()) + } +} + +pub(super) struct OwnedChild { + inner: Box, + owns_tree: bool, + tree_closed: bool, +} + +impl OwnedChild { + // Wrapper waits include descendants. The WFL result belongs to the direct + // child; once that child exits, close its remaining owned tree before + // waiting for pipe EOF. Otherwise inherited pipes can keep completion open. + pub(super) fn try_wait(&mut self) -> io::Result> { + let status = self.inner.inner_mut().try_wait()?; + if status.is_some() { + self.close_tree()?; + } + Ok(status) + } + + pub(super) async fn wait(&mut self) -> io::Result { + loop { + if let Some(status) = self.try_wait()? { + return Ok(status); + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + } + + fn close_tree(&mut self) -> io::Result<()> { + if self.owns_tree && !self.tree_closed { + // An empty Unix group no longer has an ID to signal. Every + // other termination failure must remain visible to callers. + #[cfg(unix)] + if let Err(error) = self.inner.start_kill() + && error.raw_os_error() != Some(libc::ESRCH) + { + return Err(error); + } + #[cfg(not(unix))] + self.inner.start_kill()?; + self.tree_closed = true; + } + Ok(()) + } +} + +impl Deref for OwnedChild { + type Target = dyn ChildWrapper; + fn deref(&self) -> &Self::Target { + self.inner.as_ref() + } +} + +impl DerefMut for OwnedChild { + fn deref_mut(&mut self) -> &mut Self::Target { + self.inner.as_mut() + } +} + +impl Drop for OwnedChild { + fn drop(&mut self) { + let _ = self.close_tree(); + } +} + +pub(super) fn spawn( + mut command: tokio::process::Command, + owns_tree: bool, + kill_on_drop: bool, +) -> io::Result { + if !owns_tree { + command.kill_on_drop(kill_on_drop); + return command.spawn().map(|inner| OwnedChild { + inner: Box::new(inner), + owns_tree: false, + tree_closed: false, + }); + } + + #[cfg(target_os = "linux")] + { + // A nested WFL driver creates its own child group. Parent-death + // signalling closes that nested ownership chain when the outer driver + // is forcibly terminated before WFL finally clauses can execute. + // SAFETY: this pre-exec hook calls only async-signal-safe syscalls, + // performs no allocation, and guards the race with parent death. + let parent_pid = std::process::id() as libc::pid_t; + unsafe { + command.pre_exec(move || { + if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) != 0 { + return Err(io::Error::last_os_error()); + } + if libc::getppid() != parent_pid { + libc::_exit(1); + } + Ok(()) + }); + } + } + + let mut command = CommandWrap::from(command); + command.wrap(KillOnDrop); + #[cfg(unix)] + command.wrap(process_wrap::tokio::ProcessGroup::leader()); + #[cfg(windows)] + command.wrap(process_wrap::tokio::JobObject); + command.spawn().map(|inner| OwnedChild { + inner, + owns_tree: true, + tree_closed: false, + }) +} diff --git a/src/linter/layout.rs b/src/linter/layout.rs index 829bb42d..2e57618b 100644 --- a/src/linter/layout.rs +++ b/src/linter/layout.rs @@ -180,21 +180,35 @@ fn statement_expressions<'a>(statement: &'a Statement, pending: &mut Vec<&'a Exp | Statement::TransactionStatement { db, .. } => pending.push(db), Statement::CreateFileStatement { path, content, .. } => pending.extend([path, content]), Statement::ExecuteCommandStatement { - command, arguments, .. + command, + arguments, + directory, + .. } | Statement::SpawnProcessStatement { - command, arguments, .. + command, + arguments, + directory, + .. } => { pending.push(command); pending.extend(arguments); + pending.extend(directory); } Statement::ExecuteFileStatement { path, request, .. } => { pending.push(path); pending.extend(request); } Statement::ReadProcessOutputStatement { process_id, .. } - | Statement::KillProcessStatement { process_id, .. } - | Statement::WaitForProcessStatement { process_id, .. } => pending.push(process_id), + | Statement::KillProcessStatement { process_id, .. } => pending.push(process_id), + Statement::WaitForProcessStatement { + process_id, + timeout, + .. + } => { + pending.push(process_id); + pending.extend(timeout); + } Statement::WaitForDurationStatement { duration, .. } => pending.push(duration), Statement::HttpPostStatement { url, data, .. } => pending.extend([url, data]), Statement::HttpRequestStatement { @@ -356,11 +370,11 @@ fn statement_expressions<'a>(statement: &'a Statement, pending: &mut Vec<&'a Exp | Assertion::BeOfType(_) => {} } } + Statement::ExitStatement { code, .. } => pending.extend(code), Statement::ForeverLoop { .. } | Statement::MainLoop { .. } | Statement::BreakStatement { .. } | Statement::ContinueStatement { .. } - | Statement::ExitStatement { .. } | Statement::ExportStatement { .. } | Statement::WaitForStatement { .. } | Statement::TryStatement { .. } diff --git a/src/main.rs b/src/main.rs index 6247371d..8219a251 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1130,9 +1130,15 @@ async fn run() -> io::Result<()> { // Exit with error code if tests failed if results.failed_tests > 0 { + drop(interpreter); process::exit(1); } } + let program_exit_code = interpreter.program_exit_code(); + if program_exit_code != 0 { + drop(interpreter); + process::exit(program_exit_code); + } } Err(errors) => { if config.logging_enabled { @@ -1182,6 +1188,7 @@ async fn run() -> io::Result<()> { // A program that died with a runtime error must not // report success to the shell. + drop(interpreter); process::exit(1); } } diff --git a/src/parser/ast.rs b/src/parser/ast.rs index 34eff3a8..1686be65 100644 --- a/src/parser/ast.rs +++ b/src/parser/ast.rs @@ -231,6 +231,7 @@ pub enum Statement { }, ExitStatement { scope: ExitScope, + code: Option, line: usize, column: usize, }, @@ -337,6 +338,7 @@ pub enum Statement { ExecuteCommandStatement { command: Expression, arguments: Option, + directory: Option, variable_name: Option, use_shell: bool, line: usize, @@ -355,6 +357,7 @@ pub enum Statement { SpawnProcessStatement { command: Expression, arguments: Option, + directory: Option, variable_name: String, use_shell: bool, line: usize, @@ -368,12 +371,16 @@ pub enum Statement { }, KillProcessStatement { process_id: Expression, + /// `close process` is idempotent; legacy `kill process` rejects an unknown handle. + idempotent: bool, line: usize, column: usize, }, WaitForProcessStatement { process_id: Expression, variable_name: Option, + timeout: Option, + full_result: bool, line: usize, column: usize, }, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index fb35f63d..239cee42 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -599,7 +599,9 @@ impl<'a> StmtParser<'a> for Parser<'a> { Token::KeywordClose => { // Check if it's "close server", "close database", or regular "close file" if let Some(next_token) = self.cursor.peek_next() { - if matches!(next_token.token, Token::KeywordServer) { + if matches!(next_token.token, Token::KeywordProcess) { + self.parse_kill_process_statement() + } else if matches!(next_token.token, Token::KeywordServer) { self.parse_close_server_statement() } else if matches!(next_token.token, Token::KeywordDatabase) { self.parse_close_database_statement() diff --git a/src/parser/stmt/actions.rs b/src/parser/stmt/actions.rs index 43e7d713..9660e010 100644 --- a/src/parser/stmt/actions.rs +++ b/src/parser/stmt/actions.rs @@ -1,6 +1,6 @@ //! Action definition and call statement parsing -use super::super::{Parameter, ParseError, Parser, Statement, Type}; +use super::super::{Expression, Parameter, ParseError, Parser, Statement, Type}; use super::StmtParser; use super::database::DatabaseParser; use crate::exec_trace; @@ -646,13 +646,16 @@ impl<'a> ActionParser<'a> for Parser<'a> { // `exit program` terminates the program. `loop` may arrive as its own // keyword token or as a plain identifier depending on context. let mut scope = ExitScope::Loop; + let mut explicit_loop = false; if let Some(token) = self.cursor.peek() { match &token.token { Token::KeywordLoop => { self.bump_sync(); // Consume "loop" + explicit_loop = true; } Token::Identifier(id) if id.to_lowercase() == "loop" => { self.bump_sync(); // Consume "loop" + explicit_loop = true; } Token::Identifier(id) if id.to_lowercase() == "program" => { self.bump_sync(); // Consume "program" @@ -662,8 +665,46 @@ impl<'a> ActionParser<'a> for Parser<'a> { } } + let code = if self + .cursor + .peek() + .is_some_and(|t| t.token == Token::KeywordWith) + { + self.bump_sync(); + let code_token = self.bump_sync().ok_or_else(|| { + self.cursor + .error("Expected 'code' after 'with'".to_string()) + })?; + if explicit_loop { + return Err(ParseError::from_token( + "Use 'exit program with code' to return a program status".to_string(), + code_token, + )); + } + scope = ExitScope::Program; + Some(match &code_token.token { + Token::Identifier(name) if name == "code" => self.parse_expression()?, + Token::Identifier(name) if name.starts_with("code ") => { + let lead = Expression::Variable( + name[5..].to_string(), + code_token.line, + code_token.column + 5, + ); + self.parse_seeded_expression_continuation(lead, false)? + } + _ => { + return Err(ParseError::from_token( + "Expected 'code' after 'with'".to_string(), + code_token, + )); + } + }) + } else { + None + }; Ok(Statement::ExitStatement { scope, + code, line: exit_token.line, column: exit_token.column, }) diff --git a/src/parser/stmt/processes.rs b/src/parser/stmt/processes.rs index 4f717d64..c2d0e930 100644 --- a/src/parser/stmt/processes.rs +++ b/src/parser/stmt/processes.rs @@ -35,6 +35,18 @@ impl<'a> ProcessParser<'a> for Parser<'a> { None }; + let directory = if self + .cursor + .peek() + .is_some_and(|t| t.token == Token::KeywordIn) + { + self.bump_sync(); + self.expect_token(Token::KeywordDirectory, "Expected 'directory' after 'in'")?; + Some(self.parse_primary_expression()?) + } else { + None + }; + // Check for optional "using shell" let use_shell = if let Some(token) = self.cursor.peek() && matches!(&token.token, Token::KeywordUsing) @@ -70,6 +82,7 @@ impl<'a> ProcessParser<'a> for Parser<'a> { Ok(Statement::ExecuteCommandStatement { command, arguments, + directory, variable_name, use_shell, line: token_pos.line, @@ -154,6 +167,18 @@ impl<'a> ProcessParser<'a> for Parser<'a> { None }; + let directory = if self + .cursor + .peek() + .is_some_and(|t| t.token == Token::KeywordIn) + { + self.bump_sync(); + self.expect_token(Token::KeywordDirectory, "Expected 'directory' after 'in'")?; + Some(self.parse_primary_expression()?) + } else { + None + }; + // Check for optional "using shell" let use_shell = if let Some(token) = self.cursor.peek() && matches!(&token.token, Token::KeywordUsing) @@ -184,6 +209,7 @@ impl<'a> ProcessParser<'a> for Parser<'a> { Ok(Statement::SpawnProcessStatement { command, arguments, + directory, variable_name, use_shell, line: token_pos.line, @@ -192,13 +218,18 @@ impl<'a> ProcessParser<'a> for Parser<'a> { } fn parse_kill_process_statement(&mut self) -> Result { - let token_pos = self.bump_sync().unwrap(); // Consume "kill" - self.expect_token(Token::KeywordProcess, "Expected 'process' after 'kill'")?; + let token_pos = self.bump_sync().unwrap(); // Consume "kill" or "close" + let idempotent = token_pos.token == Token::KeywordClose; + self.expect_token( + Token::KeywordProcess, + "Expected 'process' after 'kill' or 'close'", + )?; let process_id = self.parse_primary_expression()?; Ok(Statement::KillProcessStatement { process_id, + idempotent, line: token_pos.line, column: token_pos.column, }) @@ -310,6 +341,46 @@ impl<'a> ProcessParser<'a> for Parser<'a> { } } + let timeout = if self + .cursor + .peek() + .is_some_and(|t| t.token == Token::KeywordWith) + { + self.bump_sync(); + self.expect_token( + Token::KeywordTimeout, + "Expected 'timeout' after 'with'", + )?; + Some(self.parse_primary_expression()?) + } else { + None + }; + let full_result = if self + .cursor + .peek() + .is_some_and(|t| t.token == Token::KeywordAnd) + { + self.bump_sync(); + self.expect_token( + Token::KeywordRead, + "Expected 'read result' after 'and'", + )?; + let result_token = self.bump_sync().ok_or_else(|| { + self.cursor + .error("Expected 'result' after 'read'".to_string()) + })?; + if !matches!(&result_token.token, Token::Identifier(name) if name == "result") + { + return Err(ParseError::from_token( + "Expected 'result' after 'read'".to_string(), + result_token, + )); + } + true + } else { + false + }; + // Check for optional "as variable_name" let variable_name = if let Some(token) = self.cursor.peek() { if matches!(&token.token, Token::KeywordAs) { @@ -325,6 +396,8 @@ impl<'a> ProcessParser<'a> for Parser<'a> { return Ok(Statement::WaitForProcessStatement { process_id, variable_name, + timeout, + full_result, line: wait_token_pos.line, column: wait_token_pos.column, }); diff --git a/src/typechecker/mod.rs b/src/typechecker/mod.rs index 8e8d8daa..0bd8724b 100644 --- a/src/typechecker/mod.rs +++ b/src/typechecker/mod.rs @@ -4016,7 +4016,22 @@ impl TypeChecker { Type::Any }; } - Statement::ExitStatement { .. } => {} + Statement::ExitStatement { + code, line, column, .. + } => { + if let Some(code) = code { + let code_type = self.infer_expression_type(code); + if code_type != Type::Number && !self.is_gradual_type(&code_type) { + self.type_error( + "Program exit code must be a number".to_string(), + Some(Type::Number), + Some(code_type), + *line, + *column, + ); + } + } + } Statement::WaitForStatement { inner, line: _line, @@ -5976,12 +5991,25 @@ impl TypeChecker { Statement::ExecuteCommandStatement { command, arguments, + directory, variable_name, use_shell: _, line: _line, column: _column, } => { let cmd_type = self.infer_expression_type(command); + if let Some(directory) = directory { + let directory_type = self.infer_expression_type(directory); + if directory_type != Type::Text && !self.is_gradual_type(&directory_type) { + self.type_error( + "Process working directory must be text".to_string(), + Some(Type::Text), + Some(directory_type), + *_line, + *_column, + ); + } + } if cmd_type != Type::Text && !self.is_gradual_type(&cmd_type) { self.type_error( "Expected string for command".to_string(), @@ -6050,12 +6078,25 @@ impl TypeChecker { Statement::SpawnProcessStatement { command, arguments, + directory, variable_name, use_shell: _, line: _line, column: _column, } => { let cmd_type = self.infer_expression_type(command); + if let Some(directory) = directory { + let directory_type = self.infer_expression_type(directory); + if directory_type != Type::Text && !self.is_gradual_type(&directory_type) { + self.type_error( + "Process working directory must be text".to_string(), + Some(Type::Text), + Some(directory_type), + *_line, + *_column, + ); + } + } if cmd_type != Type::Text && !self.is_gradual_type(&cmd_type) { self.type_error( "Expected string for command".to_string(), @@ -6099,6 +6140,7 @@ impl TypeChecker { } Statement::KillProcessStatement { process_id, + idempotent: _, line: _line, column: _column, } => { @@ -6116,10 +6158,24 @@ impl TypeChecker { Statement::WaitForProcessStatement { process_id, variable_name, + timeout, + full_result, line: _line, column: _column, } => { let proc_type = self.infer_expression_type(process_id); + if let Some(timeout) = timeout { + let timeout_type = self.infer_expression_type(timeout); + if timeout_type != Type::Number && !self.is_gradual_type(&timeout_type) { + self.type_error( + "Process timeout must be a number of seconds".to_string(), + Some(Type::Number), + Some(timeout_type), + *_line, + *_column, + ); + } + } if proc_type != Type::Text && !self.is_gradual_type(&proc_type) { self.type_error( "Expected string for process ID".to_string(), @@ -6130,7 +6186,12 @@ impl TypeChecker { ); } if let Some(var_name) = variable_name { - self.bind_runtime_value(var_name, Type::Number, true, *_line, *_column); + let result_type = if *full_result { + Type::Map(Box::new(Type::Text), Box::new(Type::Any)) + } else { + Type::Number + }; + self.bind_runtime_value(var_name, result_type, true, *_line, *_column); } } Statement::WriteToStatement { diff --git a/tests/fixtures/process/.wflcfg b/tests/fixtures/process/.wflcfg new file mode 100644 index 00000000..c7683b2d --- /dev/null +++ b/tests/fixtures/process/.wflcfg @@ -0,0 +1,6 @@ +allow_shell_execution = true +shell_execution_mode = sanitized +kill_on_shutdown = true +timeout_seconds = 20 +execution_logging = false +debug_report_enabled = false diff --git a/tests/fixtures/process/assertion-close.test.wfl b/tests/fixtures/process/assertion-close.test.wfl new file mode 100644 index 00000000..0bd90948 --- /dev/null +++ b/tests/fixtures/process/assertion-close.test.wfl @@ -0,0 +1,14 @@ +store runtime_path as args[0] +store marker_path as args[1] +store release_path as marker_path with ".release" +store child_path as path_join of script_directory and "late-write.wfl" +describe "Intentional assertion failure": + test "finally closes the owned child": + wait for spawn command runtime_path with arguments [child_path, marker_path, release_path] as child + try: + expect 1 to equal 2 + finally: + close process child + end try + end test +end describe diff --git a/tests/fixtures/process/exit-alias.wfl b/tests/fixtures/process/exit-alias.wfl new file mode 100644 index 00000000..fa2c51f5 --- /dev/null +++ b/tests/fixtures/process/exit-alias.wfl @@ -0,0 +1 @@ +exit with code 9 diff --git a/tests/fixtures/process/exit-camel.wfl b/tests/fixtures/process/exit-camel.wfl new file mode 100644 index 00000000..d0b65c12 --- /dev/null +++ b/tests/fixtures/process/exit-camel.wfl @@ -0,0 +1,2 @@ +store statusCode as 7 +exit program with code statusCode diff --git a/tests/fixtures/process/exit-high.wfl b/tests/fixtures/process/exit-high.wfl new file mode 100644 index 00000000..4cbac3c9 --- /dev/null +++ b/tests/fixtures/process/exit-high.wfl @@ -0,0 +1 @@ +exit program with code 255 diff --git a/tests/fixtures/process/exit.wfl b/tests/fixtures/process/exit.wfl new file mode 100644 index 00000000..49fc0081 --- /dev/null +++ b/tests/fixtures/process/exit.wfl @@ -0,0 +1,9 @@ +define action called finish_child: + try: + exit program with code 7 + finally: + display "cleanup before exit" + end try +end action +call finish_child +display "must not run after exit" diff --git a/tests/fixtures/process/flood.wfl b/tests/fixtures/process/flood.wfl new file mode 100644 index 00000000..dec48ccc --- /dev/null +++ b/tests/fixtures/process/flood.wfl @@ -0,0 +1,4 @@ +count from 1 to 1000: + display "synthetic output line" +end count +display "final output tail" diff --git a/tests/fixtures/process/late-write.wfl b/tests/fixtures/process/late-write.wfl new file mode 100644 index 00000000..178d63e3 --- /dev/null +++ b/tests/fixtures/process/late-write.wfl @@ -0,0 +1,19 @@ +store marker_path as args[0] +check if (length of args) is greater than 1: + store release_path as args[1] + store ready_path as marker_path with ".ready" + open file at ready_path for writing as ready_file + wait for write content "ready" into ready_file + close file ready_file + count from 1 to 400: + check if file exists at release_path: + break + end check + wait for 50 milliseconds + end count +otherwise: + wait for 800 milliseconds +end check +open file at marker_path for writing as marker_file +wait for write content "unexpected late write" into marker_file +close file marker_file diff --git a/tests/fixtures/process/nested-driver.wfl b/tests/fixtures/process/nested-driver.wfl new file mode 100644 index 00000000..29b8bcad --- /dev/null +++ b/tests/fixtures/process/nested-driver.wfl @@ -0,0 +1,31 @@ +store runtime_path as args[0] +store marker_path as args[1] +store ready_path as args[2] +store driver_mode as args[3] +store child_path as path_join of script_directory and "late-write.wfl" +store release_path as marker_path with ".release" +store child_ready_path as marker_path with ".ready" +wait for spawn command runtime_path with arguments [child_path, marker_path, release_path] as grandchild +count from 1 to 200: + check if file exists at child_ready_path: + break + end check + wait for 50 milliseconds +end count +check if (is_file of child_ready_path) is no: + exit program with code 1 +end check +open file at ready_path for writing as ready_file +wait for write content "grandchild spawned" into ready_file +close file ready_file +check if driver_mode is "wait": + wait for 10000 milliseconds +end check +check if driver_mode is "error": + display 1 divided by 0 +end check +check if driver_mode is "status": + exit program with code 7 +end check +// Intentionally leave the child owned: normal interpreter shutdown and a +// parent's forced timeout must both close it without executing late writes. diff --git a/tests/fixtures/process/policy/.wflcfg b/tests/fixtures/process/policy/.wflcfg new file mode 100644 index 00000000..7784a85d --- /dev/null +++ b/tests/fixtures/process/policy/.wflcfg @@ -0,0 +1,6 @@ +allow_shell_execution = true +shell_execution_mode = allowlist_only +allowed_shell_commands = target/release/wfl,target/release/wfl.exe +kill_on_shutdown = true +execution_logging = false +debug_report_enabled = false diff --git a/tests/fixtures/process/policy/cwd-policy.wfl b/tests/fixtures/process/policy/cwd-policy.wfl new file mode 100644 index 00000000..99c1e9f8 --- /dev/null +++ b/tests/fixtures/process/policy/cwd-policy.wfl @@ -0,0 +1,19 @@ +store relative_runtime as "target/release/wfl" +check if file exists at (relative_runtime with ".exe"): + change relative_runtime to relative_runtime with ".exe" +end check +store child_directory as args[0] +wait for execute command relative_runtime with arguments ["--version"] in directory child_directory as foreground +check if foreground["success"] is no: + exit program with code 1 +end check +wait for spawn command relative_runtime with arguments ["--version"] in directory child_directory as child +try: + wait for process child to complete with timeout 5 and read result as background + check if background["success"] is no: + exit program with code 2 + end check +finally: + close process child +end try +display "authorized executable retained across cwd" diff --git a/tests/typechecker_statement_operand_contract_test.rs b/tests/typechecker_statement_operand_contract_test.rs index 5a0d3da3..d33f1603 100644 --- a/tests/typechecker_statement_operand_contract_test.rs +++ b/tests/typechecker_statement_operand_contract_test.rs @@ -143,6 +143,7 @@ fn command_and_process_arguments_require_text_or_list() { for statement in [ Statement::ExecuteCommandStatement { command: text("tool"), + directory: None, arguments: Some(boolean(true)), variable_name: None, use_shell: false, @@ -151,6 +152,7 @@ fn command_and_process_arguments_require_text_or_list() { }, Statement::SpawnProcessStatement { command: text("tool"), + directory: None, arguments: Some(boolean(true)), variable_name: "process".to_string(), use_shell: false, @@ -170,6 +172,7 @@ fn command_and_process_arguments_require_text_or_list() { typecheck(vec![ Statement::ExecuteCommandStatement { command: text("tool"), + directory: None, arguments: Some(text("--version")), variable_name: None, use_shell: false, @@ -178,6 +181,7 @@ fn command_and_process_arguments_require_text_or_list() { }, Statement::SpawnProcessStatement { command: text("tool"), + directory: None, arguments: Some(list(vec![text("--version")])), variable_name: "process".to_string(), use_shell: false, From 2596d29fb017245aafb5c1e3d7bbaa222e166830 Mon Sep 17 00:00:00 2001 From: Brad Byrd Date: Sun, 20 Sep 2026 04:11:55 -0500 Subject: [PATCH 3/6] test(process): specify current executable discovery --- .../process/runtime-location.test.wfl | 19 +++++++++++++++++++ tests/fixtures/process/runtime-location.wfl | 2 ++ 2 files changed, 21 insertions(+) create mode 100644 TestPrograms/process/runtime-location.test.wfl create mode 100644 tests/fixtures/process/runtime-location.wfl diff --git a/TestPrograms/process/runtime-location.test.wfl b/TestPrograms/process/runtime-location.test.wfl new file mode 100644 index 00000000..0b273bdb --- /dev/null +++ b/TestPrograms/process/runtime-location.test.wfl @@ -0,0 +1,19 @@ +describe "The currently running WFL executable": + test "the running executable is an absolute existing program": + store runtime_path as call current_executable + expect (is_file of runtime_path) to equal yes + expect (length of (path_dirname of runtime_path)) to be greater than 0 + wait for execute command runtime_path with arguments ["--version"] as outcome + expect outcome["success"] to equal yes + expect outcome["output"] to contain wfl_version + end test + + test "changing the child directory does not change executable identity": + store runtime_path as call current_executable + store repo_root as path_dirname of (path_dirname of script_directory) + store child_path as path_join of repo_root and "tests/fixtures/process/runtime-location.wfl" + wait for execute command runtime_path with arguments [child_path] in directory script_directory as outcome + expect outcome["exit_code"] to equal 0 + expect (trim of outcome["output"]) to equal runtime_path + end test +end describe diff --git a/tests/fixtures/process/runtime-location.wfl b/tests/fixtures/process/runtime-location.wfl new file mode 100644 index 00000000..aa5c7abf --- /dev/null +++ b/tests/fixtures/process/runtime-location.wfl @@ -0,0 +1,2 @@ +store runtime_path as call current_executable +display runtime_path From a32c74f1f98f64a60b31f923785b4660d8f1d6a8 Mon Sep 17 00:00:00 2001 From: Brad Byrd Date: Sun, 20 Sep 2026 04:20:36 -0500 Subject: [PATCH 4/6] feat(core): expose the running executable identity --- .../subprocess-execution.md | 5 +++-- Docs/05-standard-library/core-module.md | 19 +++++++++++++++++++ .../evidence/2026-09-20-process-lifecycle.md | 15 ++++++++++++++- .../2026/2026-09-20-owned-process-results.md | 7 +++++++ src/builtins.rs | 5 ++++- src/stdlib/core.rs | 17 +++++++++++++++++ src/stdlib/typechecker.rs | 1 + 7 files changed, 65 insertions(+), 4 deletions(-) diff --git a/Docs/04-advanced-features/subprocess-execution.md b/Docs/04-advanced-features/subprocess-execution.md index 61ad7d29..4c87f814 100644 --- a/Docs/04-advanced-features/subprocess-execution.md +++ b/Docs/04-advanced-features/subprocess-execution.md @@ -22,10 +22,11 @@ See the [configuration reference](../reference/configuration-reference.md#securi ## Launch, wait, read, and close -This complete example launches WFL from `PATH` and reads its version: +This complete example launches the same WFL executable and reads its version: ```wfl -wait for spawn command "wfl" with arguments ["--version"] as child +store runtime_path as call current_executable +wait for spawn command runtime_path with arguments ["--version"] as child try: wait for process child to complete with timeout 10 and read result as outcome display outcome["output"] diff --git a/Docs/05-standard-library/core-module.md b/Docs/05-standard-library/core-module.md index ea7535d1..38001015 100644 --- a/Docs/05-standard-library/core-module.md +++ b/Docs/05-standard-library/core-module.md @@ -4,6 +4,25 @@ The Core module provides essential functions for output, type introspection, and ## Functions +### current_executable + +**Purpose:** Return the absolute path of the executable running this program. +This takes no arguments and does not search `PATH` or enumerate environment values. + +```wfl +store runtime_path as call current_executable +display runtime_path +``` + +Use it when launching another WFL program with the exact same runtime. Changing +a child's working directory does not change the returned executable identity. +The operating system may resolve an executable symlink. Failure to locate the +program, or a path that cannot be represented as Unicode text, raises an +actionable runtime error instead of returning a lossy or guessed path. + +This identifies the host executable when WFL is embedded in another program; +it is not the path of the `.wfl` source file. Use `script_path` for that. + ### display **Purpose:** Output text or values to the console with a newline. diff --git a/Engineering/evidence/2026-09-20-process-lifecycle.md b/Engineering/evidence/2026-09-20-process-lifecycle.md index 9b055e95..9c2adb94 100644 --- a/Engineering/evidence/2026-09-20-process-lifecycle.md +++ b/Engineering/evidence/2026-09-20-process-lifecycle.md @@ -83,7 +83,20 @@ blocking lifecycle finding. features, has MSRV 1.87 (below WFL's 1.94), and is MIT/Apache-2.0 licensed. Both root and fuzz lockfiles include it without unrelated version changes. -## Required remote acceptance +## Same-runtime discovery follow-up + +Test-first commit `2596d29f` adds two WFL scenarios for `current_executable`: +the returned program exists and reports the running WFL version, and launching +it with a different working directory preserves its identity. The preceding +runtime rejects the valid call with `Undefined action 'current_executable'`. +This is missing-API availability evidence, not a failed runtime assertion. +After native registration, explicit-call catalog/arity metadata, and the +precise zero-argument Text contract were added, both WFL assertions passed. +All eight existing builtin/catalog contract tests and strict root Clippy passed. +The implementation rejects non-Unicode paths with an actionable error and +does not enumerate or expose environment variables. Shell lookup is unnecessary. + +## Required remote acceptance (all follow-ups) Linux process groups/parent-death signalling cannot be executed on this Windows host. Existing Blacksmith Linux and Windows integration/Run WFL Programs jobs, diff --git a/History/dev-diary/2026/2026-09-20-owned-process-results.md b/History/dev-diary/2026/2026-09-20-owned-process-results.md index 421d0457..4c5cc754 100644 --- a/History/dev-diary/2026/2026-09-20-owned-process-results.md +++ b/History/dev-diary/2026/2026-09-20-owned-process-results.md @@ -31,3 +31,10 @@ literal argv and cwd, output truncation, timeout cleanup, and nested children under success, runtime failure and explicit program status. See the [acceptance evidence](../../../Engineering/evidence/2026-09-20-process-lifecycle.md) for observed results and remaining platform checks. + +The WFL-only runner also needs to select its own runtime without external +`which`/`where` programs. `call current_executable` is a read-only core builtin +backed by the operating system's executable path. It rejects non-Unicode paths +instead of guessing. Its WFL tests execute the returned program and verify the +same identity after changing a child's cwd. No environment enumeration or +mutation API was added. diff --git a/src/builtins.rs b/src/builtins.rs index bec87104..c20dd9f0 100644 --- a/src/builtins.rs +++ b/src/builtins.rs @@ -254,6 +254,7 @@ const LEGACY_BUILTIN_FUNCTIONS: &[&str] = &[ /// Builtins called with `name of arguments` or `call name with arguments`. /// This inventory can grow without changing the legacy expression grammar. const EXPLICIT_CALL_BUILTIN_FUNCTIONS: &[&str] = &[ + "current_executable", "password_hash_policy", "hash_password_with_policy", "password_needs_rehash", @@ -276,6 +277,7 @@ const EXPLICIT_CALL_BUILTIN_FUNCTIONS: &[&str] = &[ /// checking must only assign callable contracts to names in this runtime list. const IMPLEMENTED_BUILTIN_FUNCTIONS: &[&str] = &[ // Core + "current_executable", "print", "typeof", "type_of", @@ -619,7 +621,8 @@ pub fn get_function_arity(name: &str) -> usize { // === TIME FUNCTIONS === // Zero argument functions - "now" | "today" | "datetime_now" | "time" | "date" | "current_date" | "utc_now" => 0, + "now" | "today" | "datetime_now" | "time" | "date" | "current_date" | "utc_now" + | "current_executable" => 0, // Single argument functions // (`timestamp` also accepts zero arguments at runtime, but it is listed // here so `timestamp of ` is not broken by zero-arg auto-invocation) diff --git a/src/stdlib/core.rs b/src/stdlib/core.rs index 424ad6e4..b3b169de 100644 --- a/src/stdlib/core.rs +++ b/src/stdlib/core.rs @@ -32,8 +32,25 @@ pub fn native_isnothing(args: Vec) -> Result { } } +/// Locate this interpreter without a shell, PATH search, or lossy path text. +pub fn native_current_executable(args: Vec) -> Result { + check_arg_count("current_executable", &args, 0)?; + let path = std::env::current_exe().map_err(|error| { + RuntimeError::new( + format!("current_executable could not locate the running program: {error}"), + 0, + 0, + ) + })?; + let path = path.to_str().ok_or_else(|| RuntimeError::new( + "current_executable cannot represent the running program's path as Unicode text; use a Unicode executable path".to_string(), 0, 0, + ))?; + Ok(Value::Text(Arc::from(path))) +} + pub fn register_core(env: &mut Environment) { env.define_native("print", native_print); + env.define_native("current_executable", native_current_executable); env.define_native("typeof", native_typeof); env.define_native("isnothing", native_isnothing); diff --git a/src/stdlib/typechecker.rs b/src/stdlib/typechecker.rs index a6cbe5f6..8956e5ca 100644 --- a/src/stdlib/typechecker.rs +++ b/src/stdlib/typechecker.rs @@ -63,6 +63,7 @@ fn repeated(value: Type, count: usize) -> Vec { } fn register_core(analyzer: &mut Analyzer) { + register(analyzer, &["current_executable"], vec![], Type::Text); // `print` is variadic; its repeated Any contract is enforced separately. register(analyzer, &["print"], vec![], Type::Nothing); register( From 012e7c8916b95218b43f2409d10ce1bb4887fa76 Mon Sep 17 00:00:00 2001 From: Brad Byrd Date: Sun, 20 Sep 2026 04:45:07 -0500 Subject: [PATCH 5/6] test(process): preserve diagnostics from timed out children --- .../process/timeout-diagnostics.test.wfl | 39 +++++++++++++++++++ .../fixtures/process/timeout-diagnostics.wfl | 8 ++++ 2 files changed, 47 insertions(+) create mode 100644 TestPrograms/process/timeout-diagnostics.test.wfl create mode 100644 tests/fixtures/process/timeout-diagnostics.wfl diff --git a/TestPrograms/process/timeout-diagnostics.test.wfl b/TestPrograms/process/timeout-diagnostics.test.wfl new file mode 100644 index 00000000..d67d7486 --- /dev/null +++ b/TestPrograms/process/timeout-diagnostics.test.wfl @@ -0,0 +1,39 @@ +store repo_root as path_dirname of (path_dirname of script_directory) +store runtime_path as call current_executable +store fixture_path as path_join of repo_root and "tests/fixtures/process/timeout-diagnostics.wfl" +store artifact_root as path_join of repo_root and "target/test-artifacts/timeout-diagnostics" and (generate_uuid) +call makedirs with artifact_root +store ready_path as path_join of artifact_root and "ready.txt" + +describe "Timed process diagnostics": + teardown: + call remove_dir with artifact_root and yes + end teardown + + test "bounded timeout preserves stdout and stderr before releasing ownership": + wait for spawn command runtime_path with arguments [fixture_path, ready_path] as child_process + store diagnostic_text as "" + try: + count from 1 to 100: + check if file exists at ready_path: + break + end check + wait for 20 milliseconds + end count + expect (is_file of ready_path) to equal yes + try: + wait for process child_process to complete with timeout 0.05 and read result as outcome + when error: + change diagnostic_text to error_message + end try + finally: + close process child_process + end try + expect diagnostic_text to contain "timeout" + expect diagnostic_text to contain "stdout before bounded wait timeout" + expect diagnostic_text to contain "timeout_stderr_marker" + expect (process child_process is running) to equal no + wait for execute command runtime_path with arguments ["--version"] as next_outcome + expect next_outcome["exit_code"] to equal 0 + end test +end describe diff --git a/tests/fixtures/process/timeout-diagnostics.wfl b/tests/fixtures/process/timeout-diagnostics.wfl new file mode 100644 index 00000000..55a33338 --- /dev/null +++ b/tests/fixtures/process/timeout-diagnostics.wfl @@ -0,0 +1,8 @@ +// A deliberate unused variable supplies a native stderr diagnostic before wait. +store timeout_stderr_marker as 1 +display "stdout before bounded wait timeout" +store ready_path as args[0] +open file at ready_path for writing as ready_file +wait for write content "ready" into ready_file +close file ready_file +wait for 10 seconds From 96aa48cb122f48c68e8e937dc4ac44cbe283f77f Mon Sep 17 00:00:00 2001 From: Brad Byrd Date: Sun, 20 Sep 2026 04:50:51 -0500 Subject: [PATCH 6/6] fix(process): retain bounded diagnostics when waits time out --- .../subprocess-execution.md | 6 +- .../evidence/2026-09-20-process-lifecycle.md | 21 ++++- .../2026/2026-09-20-owned-process-results.md | 9 ++ src/interpreter/mod.rs | 93 +++++++++++++++++-- 4 files changed, 120 insertions(+), 9 deletions(-) diff --git a/Docs/04-advanced-features/subprocess-execution.md b/Docs/04-advanced-features/subprocess-execution.md index 4c87f814..3dd33591 100644 --- a/Docs/04-advanced-features/subprocess-execution.md +++ b/Docs/04-advanced-features/subprocess-execution.md @@ -57,7 +57,11 @@ after a timeout, or more than once, so use it in `finally`. The timeout is a finite number of seconds from one nanosecond through one year; fractional seconds are supported. It covers process execution and pipe draining. -On timeout WFL closes the child before raising a `Timeout` error. The run's +On timeout WFL closes the child, drains the bounded stdout/stderr captures for +at most one additional second, and includes their retained contents under +`Subprocess stdout` and `Subprocess stderr` labels in the `Timeout` error. This +preserves diagnostics emitted before a stalled child without leaving a handle +to manage afterward. A drain limit or read failure is stated explicitly. The run's execution budget and cancellation still apply. Inside a long-lived `main loop`, a wait also receives a finite `timeout_seconds` window. diff --git a/Engineering/evidence/2026-09-20-process-lifecycle.md b/Engineering/evidence/2026-09-20-process-lifecycle.md index 9c2adb94..8cf928b1 100644 --- a/Engineering/evidence/2026-09-20-process-lifecycle.md +++ b/Engineering/evidence/2026-09-20-process-lifecycle.md @@ -96,7 +96,26 @@ All eight existing builtin/catalog contract tests and strict root Clippy passed. The implementation rejects non-Unicode paths with an actionable error and does not enumerate or expose environment variables. Shell lookup is unnecessary. -## Required remote acceptance (all follow-ups) +## Timeout diagnostics follow-up + +Independent Scriptorium runner review identified lost pre-timeout diagnostics. +Commit `012e7c89` adds a WFL child that emits stdout and a deliberately unused +variable warning on stderr, writes readiness, then stalls. The WFL test waits +for readiness before a 50ms bounded wait. Against `a32c74f1`, it fails specifically +because the Timeout error omits stdout: **0/1 passed, exit 1**. No parser or +startup failure is used as Red. The Scriptorium runner counterpart also failed +on a missing recognizable pre-timeout line while later suites still ran. + +The corrected wait preserves its typed cause and both bounded captures after +closing ownership. Diagnostic draining has a separate one-second limit; +incomplete capture and cleanup failure remain explicit. The same WFL test now +passes **1/1**, including stderr and capacity reuse, and Scriptorium's runner +suite passes **9/9**. Existing lifecycle **16/16**, ownership **2/2**, and failure +cleanup **2/2** suites remain Green. The four existing subprocess/security/cleanup +and execution-budget Rust suites pass **80/80**; strict root Clippy passes. +Independent source review found no remaining blocker in the follow-up. + +## Remote acceptance remains required Linux process groups/parent-death signalling cannot be executed on this Windows host. Existing Blacksmith Linux and Windows integration/Run WFL Programs jobs, diff --git a/History/dev-diary/2026/2026-09-20-owned-process-results.md b/History/dev-diary/2026/2026-09-20-owned-process-results.md index 4c5cc754..695611f1 100644 --- a/History/dev-diary/2026/2026-09-20-owned-process-results.md +++ b/History/dev-diary/2026/2026-09-20-owned-process-results.md @@ -38,3 +38,12 @@ backed by the operating system's executable path. It rejects non-Unicode paths instead of guessing. Its WFL tests execute the returned program and verify the same identity after changing a child's cwd. No environment enumeration or mutation API was added. + +Independent review of Scriptorium's WFL runner found that a timed-out owned wait +discarded diagnostics printed before the timeout. The WFL reproducer writes a +readiness marker, emits stdout and an intentional compiler stderr warning, then +waits. The test-first commit `012e7c89` fails on missing stdout in the error. +The wait now drains both bounded captures after termination, with a separate +one-second drain limit, and includes them in the same typed timeout error. +Handle consumption and idempotent cleanup are unchanged. No Python or Rust test +scenario was added. diff --git a/src/interpreter/mod.rs b/src/interpreter/mod.rs index 4480e99b..9b6e1580 100644 --- a/src/interpreter/mod.rs +++ b/src/interpreter/mod.rs @@ -1933,6 +1933,38 @@ impl std::fmt::Display for ExecuteCommandError { } } +/// A failed owned wait still carries the bounded diagnostics collected before +/// cleanup. The registry entry is consumed; callers never need a stale handle. +#[derive(Debug)] +struct ProcessWaitError { + cause: ExecuteCommandError, + output: String, + error: String, +} + +impl From for ProcessWaitError { + fn from(cause: ExecuteCommandError) -> Self { + Self { + cause, + output: String::new(), + error: String::new(), + } + } +} + +impl std::fmt::Display for ProcessWaitError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(&self.cause, formatter)?; + if !self.output.is_empty() { + write!(formatter, "\nSubprocess stdout:\n{}", self.output)?; + } + if !self.error.is_empty() { + write!(formatter, "\nSubprocess stderr:\n{}", self.error)?; + } + Ok(()) + } +} + /// Bytes retained from one subprocess stream plus the amount discarded after /// the configured per-stream ceiling was reached. struct CapturedProcessStream { @@ -4814,7 +4846,7 @@ impl IoClient { &self, process_id: &str, timeout: Option, - ) -> Result<(String, String, i32), ExecuteCommandError> { + ) -> Result<(String, String, i32), ProcessWaitError> { let budget = ExecutionBudget::current_or_default(); let configured_timeout = Duration::from_secs(self.config.timeout_seconds.max(1)); let deadline = foreground_command_deadline(&budget, configured_timeout)?; @@ -4909,12 +4941,50 @@ impl IoClient { result = operation => result, error = interrupt => Err(error), }; - if result.is_err() { - self.close_process(process_id, true) - .await - .map_err(ExecuteCommandError::Other)?; + match result { + Ok(completed) => Ok(completed), + Err(cause) => { + let mut failure = ProcessWaitError::from(cause); + let handle = self.process_handles.lock().await.remove(process_id); + if let Some(mut handle) = handle { + let termination = terminate_foreground_child(&mut handle.child).await; + // Once the owned tree is terminated, readers normally reach + // EOF immediately. Bound draining too: an unowned external + // descendant must not make timeout cleanup wait forever. + let drain = async { + if let Some(task) = handle.stdout_task.as_mut() { + task.await + .map_err(|error| format!("stdout reader: {error}"))??; + } + if let Some(task) = handle.stderr_task.as_mut() { + task.await + .map_err(|error| format!("stderr reader: {error}"))??; + } + Ok::<(), String>(()) + }; + let drain_result = tokio::time::timeout(Duration::from_secs(1), drain).await; + failure.output = + String::from_utf8_lossy(&handle.stdout_buffer.lock().await.read_all()) + .to_string(); + failure.error = + String::from_utf8_lossy(&handle.stderr_buffer.lock().await.read_all()) + .to_string(); + match drain_result { + Ok(Ok(())) => {}, + Ok(Err(error)) => failure.error.push_str(&format!("\nDiagnostic collection failed: {error}")), + Err(_) => failure.error.push_str("\nDiagnostic collection reached its one-second cleanup limit; retained output may be incomplete"), + } + if let Err(error) = termination { + failure.cause = ExecuteCommandError::Other(format!( + "{}; process cleanup failed: {error}", + failure.cause + )); + } + // Drop aborts any unfinished readers and releases ownership. + } + Err(failure) + } } - result } /// Check if a process is still running @@ -13441,7 +13511,8 @@ impl Interpreter { .io_client .wait_for_process_result(proc_id, timeout_value) .await - .map_err(|error| match error { + .map_err(|failure| { + let mut runtime_error = match failure.cause { ExecuteCommandError::Budget(exceeded) => self.budget_error(exceeded, *line, *column), ExecuteCommandError::Timeout { seconds } => { let seconds = timeout_value.map_or(seconds as f64, |timeout| timeout.as_secs_f64()); @@ -13449,6 +13520,14 @@ impl Interpreter { }, ExecuteCommandError::Other(message) if message.starts_with("Invalid or closed process ID:") => RuntimeError::with_kind(message, *line, *column, ErrorKind::ProcessNotFound), ExecuteCommandError::Other(message) => RuntimeError::new(message, *line, *column), + }; + if !failure.output.is_empty() { + runtime_error.message.push_str(&format!("\nSubprocess stdout:\n{}", failure.output)); + } + if !failure.error.is_empty() { + runtime_error.message.push_str(&format!("\nSubprocess stderr:\n{}", failure.error)); + } + runtime_error })?; // Store exit code in variable if provided