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/03-language-basics/error-handling.md b/Docs/03-language-basics/error-handling.md index 2ee1a5f5..75bd84b7 100644 --- a/Docs/03-language-basics/error-handling.md +++ b/Docs/03-language-basics/error-handling.md @@ -191,6 +191,19 @@ If you omit `finally`, code placed **after** `end try` still runs once a `when`/ ## Accessing Error Information +Applications and libraries can raise their own errors with +`call raise_error with "Saving the record failed: a title is required. Supply a title."`. +The message is nonempty text. The same error propagates through actions, +executes `finally` cleanup, and rolls back enclosing transactions until a +matching handler catches it. Returning `no` is an ordinary successful return, +so use a raised error when the operation must abort. + +For safe operation context around an underlying failure, pass your context +joined with `error_message` to `raise_error`. This preserves diagnostic text, +not the original specialized error kind. Avoid confidential values in error +messages. See the [application-error tests](../../TestPrograms/application_errors_test.wfl) +for executable examples. + Use `error_message` to get error details: ```wfl diff --git a/Docs/04-advanced-features/databases.md b/Docs/04-advanced-features/databases.md index 179bba6a..172cc4d0 100644 --- a/Docs/04-advanced-features/databases.md +++ b/Docs/04-advanced-features/databases.md @@ -198,6 +198,77 @@ the same time, and a handler that has no transaction of its own keeps taking a pooled connection as usual — it is never enrolled in someone else's transaction, and cannot have its writes committed or rolled back by another request. +### Application validation failures + +Use `call raise_error with "Saving the record failed: title must be text. Supply a title."` +when an application rule fails. A raised error unwinds actions and `finally` +blocks, rolls back the transaction, and reaches the caller's `when error`. +This is the same error path used by database constraint failures. An ordinary +`return no` is a successful return and still commits; returning a failure flag +does not abort a transaction. Catch an error outside the transaction when the +whole operation must roll back. Catching it inside the block handles the error, +so the block may continue and commit. + +Use an operation name and corrective action in the message. When attaching +context to an existing error, `raise_error` can accept the safe context joined +with `error_message`; the result is an ordinary application error containing +that diagnostic text. It does not preserve a caught error's specialized kind. +Do not include passwords, tokens or confidential parameter values. + +The executable [application-error suite](../../TestPrograms/application_errors_test.wfl) +demonstrates direct errors, library calls, contextual diagnostics and rollback. + +### SQLite schema changes + +For SQLite rebuilds, extend the same transaction form to +`in transaction on db for schema changes:` and close it with `end transaction`. +This mode owns one connection before setup, temporarily disables foreign-key +enforcement, and begins an immediate write transaction. It checks all foreign +keys before committing; any violation rolls back the schema, data and ledger +writes together. Enforcement is restored before the connection returns to the +pool. Failed or cancelled cleanup discards the connection instead of pooling +it with enforcement disabled. In-memory databases retain their connection on +normal success and handled failure. + +Schema transaction acquisition, including waiting for another writer, is +bounded to five seconds. A timeout reports that the transaction body has not +run and asks the caller to finish the competing operation before retrying. +There are no automatic retries. Two migration runners must read their ledger +and apply their pending work **inside** this scope so the write lock serializes +those decisions. The bound applies to acquisition, not to the duration of the +migration body. Set the program's execution budget for a total runtime limit. + +Setting `PRAGMA foreign_keys = OFF` after an ordinary transaction has begun +does not disable enforcement. Deferred foreign-key checking does not defer +`ON DELETE CASCADE` actions. Use the schema transaction mode for a +create/copy/drop/rename rebuild so extension-owned referencing rows survive. +The [schema-transaction suite](../../TestPrograms/database_schema_transaction_test.wfl) +contains that rebuild and rejected relationships. The +[recovery](../../TestPrograms/database_schema_recovery_test.wfl), +[compatibility](../../TestPrograms/database_schema_compatibility_test.wfl) and +[locking](../../TestPrograms/database_schema_lock_test.wfl) suites cover failure +recovery, ordinary returns, names, nesting, in-memory retention and competing +writers. + +The [lifecycle suite](../../TestPrograms/database_schema_lifecycle_test.wfl) +checks abrupt program exit, a killed migration process, recovery and source +fixing. The [cancellation suite](../../TestPrograms/schema_cancellation/cancellation.test.wfl) +stops a concurrent server loop while a schema transaction is suspended, then +proves rollback and foreign-key restoration on the same in-memory connection. +The transaction's registry reservation belongs to its executing block: dropping +that future removes its reservation even if the interpreter keeps running. + +This mode currently supports SQLite only and rejects nesting on the same +handle before changing any connection settings. Normal transaction syntax and +return behavior remain unchanged. `schema` and `changes` are ordinary names +outside this header; neither becomes a reserved word. + +The runtime supplies transaction safety, not a migration ledger or schema +planner. Applications still own version definitions, checksums, drift checks, +backup/recovery procedures, and preservation of indexes, triggers and sequence +values during a rebuild. A preexisting foreign-key violation also prevents a +schema transaction from committing; repair it explicitly before upgrading. + > `transaction` is not a reserved word. It is recognized only in > `in transaction on ...` and `end transaction`, so existing programs that use > `transaction` as a variable name keep working. diff --git a/Docs/04-advanced-features/subprocess-execution.md b/Docs/04-advanced-features/subprocess-execution.md index 900d4679..3dd33591 100644 --- a/Docs/04-advanced-features/subprocess-execution.md +++ b/Docs/04-advanced-features/subprocess-execution.md @@ -1,400 +1,149 @@ # 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 the same WFL executable and reads its version: ```wfl +store runtime_path as call current_executable +wait for spawn command runtime_path 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, 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. -### 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/05-standard-library/core-module.md b/Docs/05-standard-library/core-module.md index ea7535d1..71ea15ff 100644 --- a/Docs/05-standard-library/core-module.md +++ b/Docs/05-standard-library/core-module.md @@ -4,6 +4,49 @@ The Core module provides essential functions for output, type introspection, and ## Functions +### raise_error + +`call raise_error with message` raises an ordinary, catchable application +error. The message must be nonempty text and should name the operation, cause +and corrective action. The function never returns normally. Empty messages +and other types raise an actionable argument error without printing their +values. Keep confidential record values out of messages. + +Use the same function in small validation helpers and larger transactional +operations. It unwinds `finally` blocks and causes an enclosing transaction +to roll back before a caller's `when error` runs. An uncaught error makes the +program fail with a nonzero exit. Catching an error inside a transaction +handles it and permits the block to continue; returning `no` also remains a +normal successful return. + +To attach operation context, call `raise_error` with that context joined to +the caught `error_message`. This preserves useful diagnostic text but creates +an ordinary application error rather than retaining a specialized error kind. +See the [error-handling guide](../03-language-basics/error-handling.md) and +[executable examples](../../TestPrograms/application_errors_test.wfl). + +`raise_error` uses the explicit standard-library call form (`call ... with` +or `... of`), and is not a reserved keyword. + +### 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/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 872b7b0f..889ac455 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 | ✗ | @@ -369,8 +369,8 @@ connect to database at "sqlite://app.db" as db - 24 contextual keywords CAN be used as variables in certain contexts - 5 appear contextual but are actually always reserved -### "What about `secured`, `certificate`, `key`, `redirecting`, `content_type`, `transaction`, `without following redirects`?" → Not keywords -These words are recognized purely by position — inside `listen` / `respond` statements, in `in transaction on db:` / `end transaction`, or as the `and without following redirects` clause in `open url` — and are **never reserved**. Use them as variable names freely. See [Marker Words That Are Not Keywords](reserved-keywords.md#marker-words-that-are-not-keywords-at-all). +### "What about `secured`, `certificate`, `key`, `redirecting`, `content_type`, `transaction`, `schema`, `changes`, `without following redirects`?" → Not keywords +These words are recognized purely by position — inside `listen` / `respond` statements, in `in transaction on db:` / `in transaction on db for schema changes:` / `end transaction`, or as the `and without following redirects` clause in `open url` — and are **never reserved**. Use them as variable names freely. `raise_error` is a standard-library function, not a keyword. See [Marker Words That Are Not Keywords](reserved-keywords.md#marker-words-that-are-not-keywords-at-all). --- diff --git a/Docs/reference/reserved-keywords.md b/Docs/reference/reserved-keywords.md index c801c232..d09d03b2 100644 --- a/Docs/reference/reserved-keywords.md +++ b/Docs/reference/reserved-keywords.md @@ -101,8 +101,12 @@ A few words have special meaning in exactly one statement position but are **not - `redirecting` - redirect marker in `listen on port 8080 redirecting to port 8443 as server` - `content_type` - response content type marker in `respond to req with ... and content_type "text/html"` - `transaction` - transaction block marker in `in transaction on db:` and `end transaction` +- `schema`, `changes` - optional SQLite transaction mode in `in transaction on db for schema changes:` - `without following redirects` - per-request redirect clause in `open url at address and without following redirects and read response as reply`; the individual words and the complete phrase remain ordinary variable names outside that clause position +`raise_error` is an ordinary standard-library function called with `call +raise_error with message` or `raise_error of message`; it is not a keyword. + ```wfl // All perfectly valid — these words are not reserved: store key as "secret_key_456" @@ -575,7 +579,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` | @@ -608,7 +612,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-orm-prerequisites-red.md b/Engineering/evidence/2026-09-20-orm-prerequisites-red.md new file mode 100644 index 00000000..91e6b696 --- /dev/null +++ b/Engineering/evidence/2026-09-20-orm-prerequisites-red.md @@ -0,0 +1,29 @@ +# ORM prerequisite Red evidence — 2026-09-20 + +Risk class: R3 (application error unwinding, transactions, schema/data recovery). +Base source: `cb1dadaad96939a4450a6eb2b3a6a51678035b7f`. +Runtime: official Windows nightly WFL 26.9.12. + +- `wfl --test TestPrograms/database_schema_transaction_test.wfl`: one test + executed, one failed, exit 1. A create-copy-drop-rename rebuild in the existing + native transaction lost an extension-owned `ON DELETE CASCADE` child despite + `foreign_keys=OFF` and `defer_foreign_keys=ON` inside the block. Expected one + child row, observed zero. The Green test will select the additive + `for schema changes` mode on the same transaction construct; normal transaction + behavior remains unchanged. +- `wfl --test TestPrograms/application_errors_test.wfl`: the new library fixture + cannot run on the base because `raise_error` is not registered. The runtime + reports an undefined action during semantic analysis. This is evidence of + absent functionality, **not** a behavioral Red assertion or a syntax-error + regression. Scriptorium's separate executable return-failure probe demonstrates + the unsafe alternative: returning `no` from an ordinary native transaction + commits its earlier write. Ordinary Boolean-return commit semantics are not + a bug and will remain unchanged. + +All scenarios and assertions are WFL. The existing CI WFL program runner +recursively discovers these TestPrograms files and recognizes their describe +blocks. No application error helper is implemented through an unrelated parse, +division or SQL failure. Tests use synthetic file-backed SQLite and close/delete +fixtures in finally blocks. Local raw command output is retained under ignored +`target/reports/orm-prerequisites/`; durable outcomes are recorded here and in +the test-only commit preceding implementation. diff --git a/Engineering/evidence/2026-09-20-orm-prerequisites.md b/Engineering/evidence/2026-09-20-orm-prerequisites.md new file mode 100644 index 00000000..e84b9104 --- /dev/null +++ b/Engineering/evidence/2026-09-20-orm-prerequisites.md @@ -0,0 +1,180 @@ +# Application errors and SQLite schema transaction evidence + +Risk class: **R3** (migration data integrity, cancellation, connection lifecycle, +and language compatibility). Base: `cb1dadaad96939a4450a6eb2b3a6a51678035b7f`. +This is upstream runtime work required by Scriptorium's WFL ORM; no consumer +application or shared upstream checkout is modified by this branch. + +## Public contracts and test coverage + +| Acceptance criterion | Executable WFL coverage | +| --- | --- | +| Libraries raise actionable errors, callers catch them, finally runs, earlier writes roll back | `TestPrograms/application_errors_test.wfl` (5 tests) | +| Invalid dynamic error arguments do not disclose their values | Same suite: synthetic credential marker absent from diagnostic | +| SQLite table copy/drop/rename preserves extension-owned referring rows | `TestPrograms/database_schema_transaction_test.wfl` (2 tests) | +| Foreign-key check rejects and rolls back invalid schema, rows and ledger | Same schema suite | +| Error rollback restores enforcement | `TestPrograms/database_schema_recovery_test.wfl` (1 test) | +| In-memory database survives; nested scopes reject before setup; ordinary false returns commit; markers remain names | `TestPrograms/database_schema_compatibility_test.wfl` (3 tests) | +| Immediate migration lock acquisition has a five-second bound; a later attempt can succeed | `TestPrograms/database_schema_lock_test.wfl` (1 test), two handles to one real SQLite file | +| Uncaught errors produce nonzero CLI status; explicit exit and killed migration processes leave no partial schema or ledger | `TestPrograms/database_schema_lifecycle_test.wfl` (4 tests) | +| Source fixing preserves contextual schema markers while fixing unrelated names | Same lifecycle suite, actual lint/fix/parse CLI | +| Cancelling a suspended handler restores the surviving interpreter's single connection and permits a new schema transaction | `TestPrograms/schema_cancellation/cancellation.test.wfl` (1 test), actual WFL server and clients | + +Every new assertion, scenario and fixture is WFL. Fixtures are under +`tests/fixtures/application-errors/` and `tests/fixtures/schema-transactions/`. +The existing recursive Linux/Windows TestPrograms gates discover all four +suites; their failures exit nonzero. Subprocesses use direct argument lists, +loopback peers and owned artifacts under `target/test-artifacts/`, and cleanup +runs in `finally`. Parser fuzz seeds retain valid, incomplete and repeated +contextual marker forms under `fuzz/seeds/fuzz_parser/`. + +## Red evidence + +The test-only predecessor `5d87d9b7` is retained as an ancestor of the Green +implementation. Its exact baseline result and nightly provenance are recorded +in [the initial Red record](2026-09-20-orm-prerequisites-red.md). The existing +transaction syntax actually deleted the extension child during a rebuild: +expected one row, observed zero. The new builtin was absent on the baseline; +its unavailable-symbol diagnostic is explicitly not claimed as a behavioral +Red assertion. A separate consumer probe demonstrated that returning `no` +cannot abort a native block, motivating an explicit application error. + +Two further defects were reproduced before their corrective edits on +2026-09-20: + +- A candidate supporting the new marker failed the lifecycle source-fixer + case: the real `--lint --fix --in-place` command exited 2, expected 0. Its + name-wide rewrite had changed the identical contextual marker. Conservatively + protecting that spelling fixed the regression. +- Independent review found that the original registry outlived a cancelled + transaction future. A WFL peer case entered a schema transaction, signalled + readiness, then a concurrent `/stop` request broke the server loop. Post-loop + work could not reuse its single connection: expected recovery readiness + `yes`, observed `no`. A block guard now removes its exact scope/handle/slot + reservation when the future is dropped, during acquisition as well as body + execution. This also repairs the ordinary transaction lifetime gap. + +These follow-up failures were observed against the in-progress candidate and +are not represented as baseline-nightly failures. The initial attempt to use +an arbitrary sleeping handler's client disconnect did not trigger a documented +cancellation point; the final case explicitly drops the handler through +concurrent-loop shutdown. + +## Local Green verification + +Tuple: Windows x86-64, Rust/Cargo 1.98.1, WFL 26.9.12. Final source candidate: +`target/release/wfl.exe`, SHA-256 +`8c85cdf41e0400cb2534debe47f964aba028dd97f966689686ced49c1dbd9c75`. +It is a local candidate, not a released nightly replacement. + +- `cargo build --release --locked -p wfl --bin wfl` passed. +- `target/release/wfl.exe --test TestPrograms/application_errors_test.wfl`: 5/5. +- `target/release/wfl.exe --test TestPrograms/database_schema_transaction_test.wfl`: 2/2. +- `target/release/wfl.exe --test TestPrograms/database_schema_recovery_test.wfl`: 1/1. +- `target/release/wfl.exe --test TestPrograms/database_schema_compatibility_test.wfl`: 3/3. +- `target/release/wfl.exe --test TestPrograms/database_schema_lock_test.wfl`: 1/1. +- `target/release/wfl.exe --test TestPrograms/database_schema_lifecycle_test.wfl`: 4/4. +- `target/release/wfl.exe --test TestPrograms/schema_cancellation/cancellation.test.wfl`: 1/1. +- `cargo fmt --all -- --check` passed. +- `cargo clippy --all-targets --all-features -- -D warnings` passed on final source. +- `cargo check --locked --manifest-path fuzz/Cargo.toml` passed; no sustained + fuzz campaign is claimed. +- `python scripts/check_repo_hygiene.py --mode static` passed using the bundled + Python/Git runtime and a process-local safe-directory setting. +- `cargo test --locked --test database_transaction_test`: 21/21 passed before + the lifetime follow-up. The final full run below supersedes that evidence. +- Final `cargo test --all --locked`: 2,414 passed, 27 existing ignored, across + 175 result records; exit 0. No ignored test was added or changed. +- `python scripts/validate_docs_examples.py --ci --force`: 36/36 passed. +- Existing `scripts/run_web_tests.ps1`: 2/2 passed. Its existing TLS case was + skipped because OpenSSL is unavailable; Rust TLS tests passed in the full + cargo run. No skip was added or changed. +- The first existing Windows integration runner run returned 146 pass and two + 30-second timeouts: the original seven-case schema file and unchanged + `file_io_comprehensive.wfl`. Focused investigation passed the unchanged file + I/O program in 2.56 seconds. The consumer team independently measured over + ten seconds in two durable SQLite fixture DDL statements on this host drive. + Schema cases are now split into rebuild/recovery/compatibility/locking + programs, with unchanged assertions and lock limits. Only fixture setup + statements are grouped in an ordinary transaction to avoid unnecessary + individual durability flushes; database durability settings are unchanged. + The seven reorganized cases passed together in 5.23 seconds. The final full runner passed: 151 passed, 0 failed, 24 existing skips. + The first failure remains retained in `integration-final.log`; the final + passing run is recorded in `integration-split-final.log`. + +Logs are under ignored `target/reports/orm-prerequisites/`. The first hygiene +invocation could not spawn Git from the inherited executable search path; +using the bundled Git resolved the environment error. The existing Windows +runner needs a fresh child process with one combined `Path` entry because this +host injects both `Path` and `PATH`; no runner logic or test expectation changed. + +## Review, recovery and limits + +Independent review checked runtime cancellation ownership, schema cleanup, +ordinary transaction compatibility and contextual parser/fixer behavior. The +reported registry-lifetime finding was fixed, its WFL regression passed, and +source re-review found no remaining blocking issue. Maintainer approval and +remote CI are separate requirements. + +No production database was touched. Within schema mode, checked commit makes +schema/data/ledger atomic; explicit errors, exit, interrupted processes and +cancelled scopes have executable rollback/recovery coverage. A cleanup failure +discards the connection instead of returning it with foreign keys disabled. +An in-memory database may be lost if its only connection must be discarded; +the diagnostic directs callers to close and reopen the handle. Schema mode is +SQLite-only, nesting is rejected, and migrations must read their ledger under +the acquired write lock. Returning `no` is still ordinary success. + +Current local evidence covers SQLite on Windows. Linux, PostgreSQL/MariaDB +compatibility service jobs, the VS Code host matrix and final integrated-branch +CI remain required upstream gates. There is no new VS Code UI, package, +dependency, credential or external service integration. Existing Rust LSP and +runtime tests provide unchanged-surface coverage. The repository profile's +coverage and scheduled-fuzz gaps remain visible; this record claims neither +numeric coverage nor a release approval. Do not merge or release on this local +record alone. + +## Integration with approved runtime prerequisites + +After approval to merge the runtime prerequisites, this branch incorporated +the HTTP change from `eecd658c32e23abdb6f4e0cc881d8af26dacd2d2` +and the reviewed process branch from +`f54243f43769b8d6e8ebe0b8f54ed2b2a735b234`. The resulting source commit +is `933bd9ac55165178e3687f5bc965b836a5be66aa`. Additive conflicts retained +both contextual fixer protections (`schema changes` and +`without following redirects`) and both core actions (`raise_error` and +`current_executable`), including their contracts and documentation. +`git diff df6ad9a2 -- src TestPrograms tests fuzz` is empty: these source and +test trees match the previously exercised combined consumer candidate. + +A fresh Windows release build at this commit produced `target/release/wfl.exe` +with SHA-256 +`2569dd4e1e4279a850f7aaa14e0d04a8102ee17c38bc40c646cb952887ad2f5c`. +Its package version remains 26.9.12; this is a local integration binary, not +the separately versioned official nightly. The following checks were rerun +against this merged source: + +- `cargo build --release --locked` passed. +- All seven schema/error WFL suites listed above passed, 17/17 cases. +- `TestPrograms/http_redirects/redirects.test.wfl` passed, 8/8 cases. +- The process `lifecycle`, `ownership`, `failure-cleanup`, `runtime-location` + and `timeout-diagnostics` WFL suites passed, 23/23 cases. All 13 WFL suites + used the fresh release binary with `--test`; the combined total is 48/48. +- Existing Rust suites `database_transaction_test`, + `typechecker_builtin_contract_test`, `builtin_shadow_concat_test`, + `new_builtin_constant_compat_test`, `subprocess_test`, + `subprocess_security_test`, `subprocess_cleanup_test` and + `execution_budget_test` passed, 147/147, using `cargo test --locked` with + one `--test` argument for each suite. +- `cargo fmt --all -- --check`, `git diff --check`, and strict + `cargo clippy --all-targets --all-features -- -D warnings` passed. +- `cargo check --locked --manifest-path fuzz/Cargo.toml` passed. +- Existing documentation validation passed 36/36, and the static repository + hygiene check passed, using the same commands recorded above. + +These merge checks are recorded in ignored +`target/reports/orm-prerequisites/merge-*.log`. The earlier full Cargo and +Windows integration runs remain evidence for the pre-merge schema branch; +they are not presented as new full runs on this commit. Exact pushed-head +remote CI remains the final integration gate. The coordinating maintainer +owns PR merge and nightly publication. 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..72411f25 --- /dev/null +++ b/Engineering/evidence/2026-09-20-process-lifecycle.md @@ -0,0 +1,144 @@ +# 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. + +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. + +## 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. + +## 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, +full cargo/workspace gates, and exact-commit CI review remain required before +merge. Local Windows Green does not claim Linux acceptance. + +## Integration with the merged HTTP controls + +On 2026-09-20 the process branch merged upstream `main` at +`eecd658c32e23abdb6f4e0cc881d8af26dacd2d2` (HTTP PR #737). Git merged the shared +AST, interpreter, fixer, typechecker and keyword references without conflicts. +Source inspection confirmed both additive APIs remain present. No feature or +fixture assertion was changed for this integration. + +A fresh Windows release build passed all five process WFL suites (**23/23**) +and the merged HTTP redirect/header suite (**8/8**). The existing subprocess +comprehensive program completed all eight groups and the legacy exit program +returned its intended status. The five existing execution-budget, subprocess, +security, cleanup and operand-contract Rust suites passed **106/106**. +Strict root Clippy, formatting, static hygiene and `git diff --check` passed. +Local logs remain under ignored `target/process-main-merge/`. The comprehensive +legacy program retains its previously observed live-child shutdown warning; +its existing behavior and assertions were preserved. + +The merge commit still requires exact-head Linux/Windows CI inspection before +the PR is merged. This local integration evidence does not replace that gate. diff --git a/History/dev-diary/2026/2026-09-20-application-errors-schema-transactions.md b/History/dev-diary/2026/2026-09-20-application-errors-schema-transactions.md new file mode 100644 index 00000000..63bbd9e3 --- /dev/null +++ b/History/dev-diary/2026/2026-09-20-application-errors-schema-transactions.md @@ -0,0 +1,39 @@ +# Application errors and SQLite schema transactions + +The Scriptorium ORM capability audit found two missing runtime operations: +library validation could not raise a meaningful error, and a SQLite rebuild +could not configure the transaction's connection before BEGIN. Returning a +failure flag committed earlier writes, while disabling foreign keys inside +the existing block did nothing and a parent-table drop cascaded into +extension-owned rows. + +The additive API is `raise_error of message` (also `call raise_error with +message`) and `in transaction on db for schema changes:`. Beginners and +experienced callers use the same error and transaction forms. Ordinary +transactions, Boolean returns, and identifier names retain their meanings. + +Schema mode owns a pooled connection through setup, immediate write-lock +acquisition, foreign-key validation, commit or rollback, and enforcement +restoration. Acquisition has a five-second limit. Cancellation cleanup keeps +the connection out of the pool until it is safe, closing it if cleanup itself +fails or is cancelled. Successful cleanup preserves in-memory databases. + +New executable scenarios and fixtures are WFL, discovered by the existing +Linux/Windows WFL program gates. The test-only predecessor retains the real +file-backed child-row loss and identifies the absent application-error builtin +without claiming an unavailable-builtin diagnostic is behavioral Red evidence. +Technical review and full validation remain requirements of the change record. + +Independent review found a lifecycle gap in the existing transaction registry: +dropping a concurrent handler left its transaction in the surviving interpreter. +A real WFL server regression reproduced this by stopping a loop while a schema +scope was suspended. A block guard now removes its exact registry slot on every +dropped future, including acquisition cancellation. The short registry lock is +synchronous so Drop can perform that removal reliably; database operations +retain their separate asynchronous slot locks. This also protects ordinary +transactions without changing their commit rules. + +The WFL source-fixer regression caught a separate contextual-marker collision. +When a variable is named `schema changes`, fixing all matching identifier tokens +would rewrite the transaction header. The existing conservative name protection +now covers this marker, while unrelated variable spelling fixes still apply. 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..695611f1 --- /dev/null +++ b/History/dev-diary/2026/2026-09-20-owned-process-results.md @@ -0,0 +1,49 @@ +# 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. + +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. + +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/TestPrograms/application_errors_test.wfl b/TestPrograms/application_errors_test.wfl new file mode 100644 index 00000000..07534646 --- /dev/null +++ b/TestPrograms/application_errors_test.wfl @@ -0,0 +1,83 @@ +include from "../tests/fixtures/application-errors/library.wfl" +// Application errors are ordinary catchable errors, including in transactions. +create directory at "target/test-artifacts/application-errors" +store fixture_path as "target/test-artifacts/application-errors/errors.db" +store fixture_url as "sqlite://" with fixture_path + +describe "Application errors": + test "a library can raise an actionable error": + store caught_message as "" + try: + call reject_record + when error: + change caught_message to error_message + end try + expect caught_message contains "Saving posts failed: title must be text. Supply a title." to be yes + end test + test "a raised application error rolls back earlier writes and always cleans up": + open database at fixture_url as conn + try: + store dropped as execute conn with "DROP TABLE IF EXISTS records" + store created as execute conn with "CREATE TABLE records (title TEXT)" + store cleaned as no + store caught_message as "" + try: + in transaction on conn: + store inserted as execute conn with "INSERT INTO records VALUES ('must vanish')" + try: + call reject_record + finally: + change cleaned to yes + end try + end transaction + when error: + change caught_message to error_message + end try + expect cleaned to be yes + expect caught_message contains "title must be text" to be yes + store rows as query conn with "SELECT * FROM records" + expect length of rows to equal 0 + finally: + close database conn + delete file at fixture_path + end try + end test + test "a library may attach safe context and preserve the underlying diagnostic": + store caught_message as "" + try: + try: + store quotient as 1 divided by 0 + when error: + call contextual_error with error_message + end try + when error: + change caught_message to error_message + end try + expect caught_message contains "Calculating the page count failed" to be yes + expect caught_message contains "zero" to be yes + end test + test "empty messages fail with an actionable diagnostic": + store caught_message as "" + try: + call raise_error with " " + when error: + change caught_message to error_message + end try + expect caught_message contains "nonempty text message" to be yes + end test + test "invalid dynamic arguments do not leak their values": + store private_value as parse_json of "{\"credential\":\"fixture-secret-never-log\"}" + store caught_message as "" + try: + call raise_error with private_value + when error: + change caught_message to error_message + end try + expect caught_message contains "nonempty text message" to be yes + expect caught_message contains "fixture-secret-never-log" to be no + end test +end describe + + + + diff --git a/TestPrograms/database_schema_compatibility_test.wfl b/TestPrograms/database_schema_compatibility_test.wfl new file mode 100644 index 00000000..d09647e2 --- /dev/null +++ b/TestPrograms/database_schema_compatibility_test.wfl @@ -0,0 +1,81 @@ +// Schema changes preserve foreign-key references and validate before commit. +create directory at "target/test-artifacts/schema-transactions" +store fixture_path as "target/test-artifacts/schema-transactions/compatibility.db" +store fixture_url as "sqlite://" with fixture_path + +define action called finish_normally needs connection: + in transaction on connection for schema changes: + store created as execute connection with "CREATE TABLE ordinary_return (id INTEGER)" + return no + end transaction +end action + +describe "Schema compatibility": + test "ordinary false returns commit and marker words remain ordinary names": + store schema as "schema" + store changes as "changes" + store schema changes as "marker names remain usable" + expect schema with " " with changes to equal "schema changes" + expect schema changes to equal "marker names remain usable" + open database at fixture_url as conn + try: + store result as finish_normally of conn + expect result to be no + store rows as query conn with "SELECT name FROM sqlite_schema WHERE name = 'ordinary_return'" + expect length of rows to equal 1 + finally: + close database conn + delete file at fixture_path + end try + end test + + test "nesting is rejected before changing the outer connection settings": + open database at fixture_url as conn + try: + store rejected as no + in transaction on conn: + try: + in transaction on conn for schema changes: + store unreachable_rows as query conn with "SELECT 1" + end transaction + when error: + change rejected to yes + end try + store fk_rows as query conn with "PRAGMA foreign_keys" + store fk_row as fk_rows[0] + expect fk_row["foreign_keys"] to equal 1 + end transaction + expect rejected to be yes + finally: + close database conn + delete file at fixture_path + end try + end test + + test "in-memory databases keep their single connection and enforce references after completion": + open database at "sqlite::memory:" as conn + try: + in transaction on conn for schema changes: + store created as execute conn with "CREATE TABLE parent_rows (id INTEGER PRIMARY KEY)" + store created_child as execute conn with "CREATE TABLE child_rows (parent_id INTEGER REFERENCES parent_rows(id))" + store inserted as execute conn with "INSERT INTO parent_rows VALUES (1)" + end transaction + store rows as query conn with "SELECT * FROM parent_rows" + expect length of rows to equal 1 + store rejected as no + try: + store invalid_child as execute conn with "INSERT INTO child_rows VALUES (99)" + when error: + change rejected to yes + end try + expect rejected to be yes + in transaction on conn for schema changes: + store valid_child as execute conn with "INSERT INTO child_rows VALUES (1)" + end transaction + store child_rows as query conn with "SELECT * FROM child_rows" + expect length of child_rows to equal 1 + finally: + close database conn + end try + end test +end describe diff --git a/TestPrograms/database_schema_lifecycle_test.wfl b/TestPrograms/database_schema_lifecycle_test.wfl new file mode 100644 index 00000000..a1ed9155 --- /dev/null +++ b/TestPrograms/database_schema_lifecycle_test.wfl @@ -0,0 +1,123 @@ +// Real process and file boundaries: partial schema and ledger never survive. +create directory at "target/test-artifacts/schema-lifecycle" +store fixture_path as "target/test-artifacts/schema-lifecycle/lifecycle.db" +store fixture_url as "sqlite://" with fixture_path +store ready_path as "target/test-artifacts/schema-lifecycle/ready.txt" +store runtime_path as "target/release/wfl" +check if file exists at "target/release/wfl.exe": + change runtime_path to "target/release/wfl.exe" +otherwise: + check if file exists at "target/release/wfl": + change runtime_path to "target/release/wfl" + otherwise: + check if file exists at "target/debug/wfl.exe": + change runtime_path to "target/debug/wfl.exe" + otherwise: + change runtime_path to "target/debug/wfl" + end check + end check +end check + +define action called prepare_fixture: + open database at fixture_url as conn + try: + store made_ledger as execute conn with "CREATE TABLE migration_ledger (version TEXT)" + finally: + close database conn + end try +end action + +define action called verify_recovery: + open database at fixture_url as conn + try: + store ledger_rows as query conn with "SELECT * FROM migration_ledger" + expect length of ledger_rows to equal 0 + store schema_rows as query conn with "SELECT name FROM sqlite_schema WHERE name = 'abandoned_schema'" + expect length of schema_rows to equal 0 + in transaction on conn for schema changes: + store created as execute conn with "CREATE TABLE completed_schema (id INTEGER)" + store ledger_write as execute conn with "INSERT INTO migration_ledger VALUES ('complete')" + end transaction + store completed_rows as query conn with "SELECT * FROM migration_ledger" + expect length of completed_rows to equal 1 + store fk_rows as query conn with "PRAGMA foreign_keys" + store fk_row as fk_rows[0] + expect fk_row["foreign_keys"] to equal 1 + finally: + close database conn + end try +end action + +describe "Schema transaction lifecycle": + test "lint fixes preserve contextual schema words that are also names": + store source_path as "target/test-artifacts/schema-lifecycle/fixable.wfl" + store source_text as "store schema changes as \"ordinary name\"\nstore readableName as 1\ndisplay readableName\nopen database at \"sqlite::memory:\" as conn\nin transaction on conn for schema changes:\n display schema changes\nend transaction\nclose database conn\n" + create file at source_path with source_text + try: + wait for execute command runtime_path with arguments ["--lint", "--fix", "--in-place", source_path] as fix_result + expect fix_result["exit_code"] to equal 0 + open file at source_path for reading as source_file + wait for store fixed_text as read content from source_file + close file source_file + expect fixed_text contains "for schema changes:" to be yes + expect fixed_text contains "store readable_name as 1" to be yes + wait for execute command runtime_path with arguments ["--parse", source_path] as parse_result + expect parse_result["exit_code"] to equal 0 + finally: + delete file at source_path + end try + end test + + test "an uncaught application error is a failing command with useful context": + wait for execute command runtime_path with arguments ["tests/fixtures/application-errors/uncaught.wfl"] as result + expect result["success"] to be no + expect result["exit_code"] is greater than 0 to be yes + expect result["error"] contains "required title missing" to be yes + expect result["error"] contains "Supply a title" to be yes + end test + + test "explicit exit abandons schema and ledger together": + try: + call prepare_fixture + execute wfl file at "../tests/fixtures/schema-transactions/exit.wfl" + call verify_recovery + finally: + delete file at fixture_path + end try + end test + + test "a killed writer leaves no partial schema and permits a new migration": + try: + call prepare_fixture + wait for spawn command runtime_path with arguments ["tests/fixtures/schema-transactions/interrupted.wfl"] as child + try: + store ready as no + count from 1 to 100: + check if file exists at ready_path: + change ready to yes + break + end check + wait for 50 milliseconds + end count + expect ready to be yes + store child_running as process child is running + expect child_running to be yes + kill process child + expect process child is running to be no + call verify_recovery + finally: + store still_running as process child is running + check if still_running: + kill process child + end check + end try + finally: + check if file exists at ready_path: + delete file at ready_path + end check + check if file exists at fixture_path: + delete file at fixture_path + end check + end try + end test +end describe diff --git a/TestPrograms/database_schema_lock_test.wfl b/TestPrograms/database_schema_lock_test.wfl new file mode 100644 index 00000000..414768ab --- /dev/null +++ b/TestPrograms/database_schema_lock_test.wfl @@ -0,0 +1,43 @@ +// Schema changes preserve foreign-key references and validate before commit. +create directory at "target/test-artifacts/schema-transactions" +store fixture_path as "target/test-artifacts/schema-transactions/locking.db" +store fixture_url as "sqlite://" with fixture_path + +describe "Schema locking": + test "a competing writer is bounded and can retry after its owner completes": + open database at fixture_url as first_conn + open database at fixture_url as second_conn + try: + store rejected as no + store caught_message as "" + store body_ran as no + in transaction on first_conn for schema changes: + store created as execute first_conn with "CREATE TABLE serialized (id INTEGER)" + store started_at as timestamp of now + try: + in transaction on second_conn for schema changes: + change body_ran to yes + end transaction + when error: + change caught_message to error_message + change rejected to yes + end try + store finished_at as timestamp of now + store duration as finished_at minus started_at + expect duration is less than 8 to be yes + end transaction + expect rejected to be yes + expect body_ran to be no + expect caught_message contains "5 seconds" to be yes + in transaction on second_conn for schema changes: + store inserted as execute second_conn with "INSERT INTO serialized VALUES (1)" + end transaction + store rows as query first_conn with "SELECT * FROM serialized" + expect length of rows to equal 1 + finally: + close database first_conn + close database second_conn + delete file at fixture_path + end try + end test +end describe diff --git a/TestPrograms/database_schema_recovery_test.wfl b/TestPrograms/database_schema_recovery_test.wfl new file mode 100644 index 00000000..29541e67 --- /dev/null +++ b/TestPrograms/database_schema_recovery_test.wfl @@ -0,0 +1,35 @@ +// Schema changes preserve foreign-key references and validate before commit. +create directory at "target/test-artifacts/schema-transactions" +store fixture_path as "target/test-artifacts/schema-transactions/recovery.db" +store fixture_url as "sqlite://" with fixture_path + +describe "Schema recovery": + test "raised errors roll back schema changes and restore enforcement": + open database at fixture_url as conn + try: + store caught_message as "" + try: + in transaction on conn for schema changes: + store created as execute conn with "CREATE TABLE never_applied (id INTEGER)" + call raise_error with "Migration v2 cannot proceed: invalid model. Repair the definition." + end transaction + when error: + change caught_message to error_message + end try + expect caught_message contains "Migration v2 cannot proceed" to be yes + store schema_rows as query conn with "SELECT name FROM sqlite_schema WHERE name = 'never_applied'" + expect length of schema_rows to equal 0 + store fk_rows as query conn with "PRAGMA foreign_keys" + store fk_row as fk_rows[0] + expect fk_row["foreign_keys"] to equal 1 + in transaction on conn for schema changes: + store created as execute conn with "CREATE TABLE recovered (id INTEGER)" + end transaction + store recovered_rows as query conn with "SELECT name FROM sqlite_schema WHERE name = 'recovered'" + expect length of recovered_rows to equal 1 + finally: + close database conn + delete file at fixture_path + end try + end test +end describe diff --git a/TestPrograms/database_schema_transaction_test.wfl b/TestPrograms/database_schema_transaction_test.wfl new file mode 100644 index 00000000..1725c6ec --- /dev/null +++ b/TestPrograms/database_schema_transaction_test.wfl @@ -0,0 +1,69 @@ +// Schema changes preserve foreign-key references and validate before commit. +create directory at "target/test-artifacts/schema-transactions" +store fixture_path as "target/test-artifacts/schema-transactions/rebuild.db" +store fixture_url as "sqlite://" with fixture_path + +describe "Schema rebuilds": + test "a table rebuild preserves extension-owned referencing rows": + open database at fixture_url as conn + try: + in transaction on conn: + store dropped_child as execute conn with "DROP TABLE IF EXISTS extension_children" + store dropped_parent as execute conn with "DROP TABLE IF EXISTS managed_parent" + store dropped_new as execute conn with "DROP TABLE IF EXISTS replacement" + store made_parent as execute conn with "CREATE TABLE managed_parent (id INTEGER PRIMARY KEY, label TEXT)" + store made_child as execute conn with "CREATE TABLE extension_children (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES managed_parent(id) ON DELETE CASCADE)" + store seeded_parent as execute conn with "INSERT INTO managed_parent VALUES (1, 'preserve')" + store seeded_child as execute conn with "INSERT INTO extension_children VALUES (1, 1)" + end transaction + in transaction on conn for schema changes: + store make_new as execute conn with "CREATE TABLE replacement (id INTEGER PRIMARY KEY, label TEXT, added TEXT)" + store copy_rows as execute conn with "INSERT INTO replacement (id, label) SELECT id, label FROM managed_parent" + store drop_old as execute conn with "DROP TABLE managed_parent" + store rename_new as execute conn with "ALTER TABLE replacement RENAME TO managed_parent" + end transaction + store rows as query conn with "SELECT id, parent_id FROM extension_children" + expect length of rows to equal 1 + store child_row as rows[0] + expect child_row["parent_id"] to equal 1 + finally: + close database conn + delete file at fixture_path + end try + end test + + test "an invalid relationship rolls back schema and ledger together": + open database at fixture_url as conn + try: + in transaction on conn: + store made_parent as execute conn with "CREATE TABLE parent_rows (id INTEGER PRIMARY KEY)" + store made_child as execute conn with "CREATE TABLE child_rows (parent_id INTEGER REFERENCES parent_rows(id))" + store made_ledger as execute conn with "CREATE TABLE migration_ledger (version TEXT)" + end transaction + store caught_message as "" + try: + in transaction on conn for schema changes: + store broken_relation as execute conn with "INSERT INTO child_rows VALUES (99)" + store created as execute conn with "CREATE TABLE incomplete_schema (id INTEGER)" + store ledger_write as execute conn with "INSERT INTO migration_ledger VALUES ('v1')" + end transaction + when error: + change caught_message to error_message + end try + expect caught_message contains "foreign-key violation" to be yes + expect caught_message contains "child_rows" to be yes + store rows as query conn with "SELECT * FROM child_rows" + expect length of rows to equal 0 + store ledger_rows as query conn with "SELECT * FROM migration_ledger" + expect length of ledger_rows to equal 0 + store schema_rows as query conn with "SELECT name FROM sqlite_schema WHERE name = 'incomplete_schema'" + expect length of schema_rows to equal 0 + store fk_rows as query conn with "PRAGMA foreign_keys" + store fk_row as fk_rows[0] + expect fk_row["foreign_keys"] to equal 1 + finally: + close database conn + delete file at fixture_path + end try + end test +end describe diff --git a/TestPrograms/process/.wflcfg b/TestPrograms/process/.wflcfg new file mode 100644 index 00000000..63209022 --- /dev/null +++ b/TestPrograms/process/.wflcfg @@ -0,0 +1,9 @@ +# Trusted, synthetic subprocess conformance fixtures only. +allow_shell_execution = true +shell_execution_mode = sanitized +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 new file mode 100644 index 00000000..37fdf413 --- /dev/null +++ b/TestPrograms/process/lifecycle.test.wfl @@ -0,0 +1,232 @@ +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-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 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 case_root + expect outcome["output"] to contain "spaces ; $ literal" + expect current_directory to equal repo_root + end test + + 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 process child_process to complete with timeout 0.05 and read result as outcome + when error: + change saw_timeout to (error_message contains "timeout") + finally: + close process child_process + end try + 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 "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 + 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/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/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/TestPrograms/schema_cancellation/.wflcfg b/TestPrograms/schema_cancellation/.wflcfg new file mode 100644 index 00000000..9c63924a --- /dev/null +++ b/TestPrograms/schema_cancellation/.wflcfg @@ -0,0 +1,8 @@ +allow_shell_execution = true +shell_execution_mode = allowlist_only +allowed_shell_commands = target/release/wfl, target/release/wfl.exe +timeout_seconds = 15 +logging_enabled = false +debug_report_enabled = false +execution_logging = false +kill_on_shutdown = true diff --git a/TestPrograms/schema_cancellation/cancellation.test.wfl b/TestPrograms/schema_cancellation/cancellation.test.wfl new file mode 100644 index 00000000..511d9f69 --- /dev/null +++ b/TestPrograms/schema_cancellation/cancellation.test.wfl @@ -0,0 +1,88 @@ +// Concurrent loop shutdown drops a handler with a live schema guard. +create directory at "target/test-artifacts/schema-cancellation" +store ready_path as "target/test-artifacts/schema-cancellation/started.txt" +store recovered_path as "target/test-artifacts/schema-cancellation/recovered.json" +store runtime_binary as "target/release/wfl" +check if file exists at "target/release/wfl.exe": + change runtime_binary to "target/release/wfl.exe" +end check + +describe "Cancelled schema transaction": + test "a server restores its single connection after a suspended handler is cancelled": + wait for spawn command runtime_binary with arguments ["tests/fixtures/schema-transactions/cancellation-server.wfl"] as fixture_process + store startup_text as "" + store fixture_url as "" + try: + count from 1 to 500: + wait for read output from process fixture_process as fresh_text + change startup_text to startup_text with fresh_text + store output_lines as split startup_text by "\n" + for each output_line in output_lines: + check if output_line starts with "READY ": + change fixture_url to trim of (substring of output_line and 6 and ((length of output_line) minus 6)) + end check + end for + check if fixture_url is not equal to "": + break + end check + check if (process fixture_process is running) is equal to no: + break + end check + wait for 10 milliseconds + end count + expect fixture_url is not equal to "" to be yes + store cancel_url as fixture_url with "/cancel" + wait for spawn command runtime_binary with arguments ["tests/fixtures/schema-transactions/cancellation-client.wfl" and cancel_url] as client_process + try: + store ready as no + count from 1 to 100: + check if file exists at ready_path: + change ready to yes + break + end check + wait for 50 milliseconds + end count + expect ready to be yes + expect process client_process is running to be yes + store stop_url as fixture_url with "/stop" + open url at stop_url and read response as reply + expect reply["status"] to equal 200 + store recovered as no + count from 1 to 100: + check if file exists at recovered_path: + change recovered to yes + break + end check + wait for 50 milliseconds + end count + expect recovered to be yes + open file at recovered_path for reading as recovery_file + wait for store recovery_text as read content from recovery_file + close file recovery_file + store result as parse_json of recovery_text + expect result["abandoned_rows"] to equal 0 + expect result["abandoned_tables"] to equal 0 + expect result["enforcement"] to equal 1 + expect result["completed_rows"] to equal 1 + wait for process fixture_process to complete as fixture_exit + expect fixture_exit to equal 0 + finally: + check if process client_process is running: + kill process client_process + otherwise: + wait for process client_process to complete + end check + end try + finally: + check if process fixture_process is running: + kill process fixture_process + end check + check if file exists at ready_path: + delete file at ready_path + end check + check if file exists at recovered_path: + delete file at recovered_path + end check + end try + 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/fuzz/seeds/fuzz_parser/seed_schema_transaction.wfl b/fuzz/seeds/fuzz_parser/seed_schema_transaction.wfl new file mode 100644 index 00000000..b3bd157d --- /dev/null +++ b/fuzz/seeds/fuzz_parser/seed_schema_transaction.wfl @@ -0,0 +1,6 @@ +store schema changes as "ordinary name" +open database at "sqlite::memory:" as conn +in transaction on conn for schema changes: + display schema changes +end transaction +close database conn diff --git a/fuzz/seeds/fuzz_parser/seed_schema_transaction_duplicate.wfl b/fuzz/seeds/fuzz_parser/seed_schema_transaction_duplicate.wfl new file mode 100644 index 00000000..587568ba --- /dev/null +++ b/fuzz/seeds/fuzz_parser/seed_schema_transaction_duplicate.wfl @@ -0,0 +1,3 @@ +in transaction on conn for schema changes for schema changes: + call raise_error with "Repair the schema definition." +end transaction diff --git a/fuzz/seeds/fuzz_parser/seed_schema_transaction_incomplete.wfl b/fuzz/seeds/fuzz_parser/seed_schema_transaction_incomplete.wfl new file mode 100644 index 00000000..0af29a57 --- /dev/null +++ b/fuzz/seeds/fuzz_parser/seed_schema_transaction_incomplete.wfl @@ -0,0 +1,3 @@ +in transaction on conn for schema: + call raise_error with "Repair the schema definition." +end transaction 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/builtins.rs b/src/builtins.rs index bec87104..8a473d96 100644 --- a/src/builtins.rs +++ b/src/builtins.rs @@ -254,6 +254,8 @@ 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] = &[ + "raise_error", + "current_executable", "password_hash_policy", "hash_password_with_policy", "password_needs_rehash", @@ -276,6 +278,8 @@ const EXPLICIT_CALL_BUILTIN_FUNCTIONS: &[&str] = &[ /// checking must only assign callable contracts to names in this runtime list. const IMPLEMENTED_BUILTIN_FUNCTIONS: &[&str] = &[ // Core + "raise_error", + "current_executable", "print", "typeof", "type_of", @@ -527,6 +531,7 @@ pub fn get_function_arity(name: &str) -> usize { "print" => 1, "typeof" | "type_of" => 1, "isnothing" | "is_nothing" => 1, + "raise_error" => 1, // === MATH FUNCTIONS === // Single argument functions @@ -619,7 +624,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/fixer/source.rs b/src/fixer/source.rs index 2379a300..b58e734e 100644 --- a/src/fixer/source.rs +++ b/src/fixer/source.rs @@ -412,6 +412,14 @@ fn rename_locals( protected.insert(name.as_str()); protect_pattern_names(pattern, &mut protected); } + Statement::TransactionStatement { + schema_changes: true, + .. + } => { + // A contextual marker can also be an ordinary variable name. + // Name-wide spelling fixes must preserve the grammar marker. + protected.insert("schema changes"); + } Statement::HttpRequestStatement { follow_redirects: false, .. @@ -431,6 +439,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/database.rs b/src/interpreter/database.rs index 07c16e78..0abb48bd 100644 --- a/src/interpreter/database.rs +++ b/src/interpreter/database.rs @@ -26,6 +26,9 @@ use std::collections::HashMap; use std::rc::Rc; use std::sync::Arc; +mod schema; +pub use schema::SchemaTransaction; + const MAX_POOL_CONNECTIONS: u32 = 5; /// A connection pool to one of the supported database backends. @@ -49,6 +52,7 @@ pub enum DbTransaction { Postgres(sqlx::Transaction<'static, sqlx::Postgres>), MySql(sqlx::Transaction<'static, sqlx::MySql>), Sqlite(sqlx::Transaction<'static, sqlx::Sqlite>), + SqliteSchema(SchemaTransaction), } /// Where a statement runs: straight against the pool (any free connection), or @@ -267,6 +271,9 @@ pub async fn run_query( DbTarget::Transaction(DbTransaction::Sqlite(tx)) => { fetch!(&mut **tx, bind_sqlite, sqlite_row_to_value) } + DbTarget::Transaction(DbTransaction::SqliteSchema(tx)) => { + fetch!(tx.connection(), bind_sqlite, sqlite_row_to_value) + } DbTarget::Transaction(DbTransaction::Postgres(tx)) => { fetch!(&mut **tx, bind_postgres, pg_row_to_value) } @@ -316,6 +323,9 @@ pub async fn run_execute( DbTarget::Transaction(DbTransaction::Sqlite(tx)) => { run!(&mut **tx, bind_sqlite, sqlite_id) } + DbTarget::Transaction(DbTransaction::SqliteSchema(tx)) => { + run!(tx.connection(), bind_sqlite, sqlite_id) + } DbTarget::Transaction(DbTransaction::Postgres(tx)) => { run!(&mut **tx, bind_postgres, pg_id) } @@ -362,12 +372,25 @@ pub async fn begin(pool: &DbPool) -> Result { } } +pub async fn begin_schema(pool: &DbPool) -> Result { + match pool { + DbPool::Sqlite(pool) => SchemaTransaction::begin(pool) + .await + .map(DbTransaction::SqliteSchema), + _ => Err("Transactions 'for schema changes' currently require SQLite. Use an ordinary transaction for this database backend.".to_string()), + } +} + /// Commit a transaction, returning its connection to the pool. pub async fn commit(tx: DbTransaction) -> Result<(), String> { + if let DbTransaction::SqliteSchema(tx) = tx { + return tx.commit().await; + } match tx { DbTransaction::Sqlite(tx) => tx.commit().await, DbTransaction::Postgres(tx) => tx.commit().await, DbTransaction::MySql(tx) => tx.commit().await, + DbTransaction::SqliteSchema(_) => unreachable!(), } .map_err(|e| format!("Failed to commit transaction: {e}")) } @@ -378,10 +401,14 @@ pub async fn commit(tx: DbTransaction) -> Result<(), String> { /// so the interpreter can report a rollback that itself fails, rather than /// discarding it silently. pub async fn rollback(tx: DbTransaction) -> Result<(), String> { + if let DbTransaction::SqliteSchema(tx) = tx { + return tx.rollback().await; + } match tx { DbTransaction::Sqlite(tx) => tx.rollback().await, DbTransaction::Postgres(tx) => tx.rollback().await, DbTransaction::MySql(tx) => tx.rollback().await, + DbTransaction::SqliteSchema(_) => unreachable!(), } .map_err(|e| format!("Failed to roll back transaction: {e}")) } diff --git a/src/interpreter/database/schema.rs b/src/interpreter/database/schema.rs new file mode 100644 index 00000000..b429b360 --- /dev/null +++ b/src/interpreter/database/schema.rs @@ -0,0 +1,161 @@ +//! SQLite schema transactions own their connection from setup through cleanup. +//! +//! The foreign-key switch must happen before BEGIN. A checked commit validates +//! all references before making the schema and its migration ledger durable. +use sqlx::pool::PoolConnection; +use sqlx::{Row, Sqlite, SqliteConnection, SqlitePool}; +use std::time::Duration; + +const LOCK_WAIT: Duration = Duration::from_secs(5); + +pub struct SchemaTransaction { + connection: Option>, +} + +/// If cleanup itself is cancelled, never return an unconfigured connection. +struct CleanupConnection(Option>); + +impl Drop for CleanupConnection { + fn drop(&mut self) { + if let Some(connection) = self.0.as_mut() { + connection.close_on_drop(); + } + } +} + +impl SchemaTransaction { + pub async fn begin(pool: &SqlitePool) -> Result { + let begin = async { + let connection = pool.acquire().await?; + // Install the cancellation guard before touching connection state. + let mut transaction = Self { + connection: Some(connection), + }; + sqlx::query("PRAGMA busy_timeout = 5000") + .execute(transaction.connection()) + .await?; + sqlx::query("PRAGMA foreign_keys = OFF") + .execute(transaction.connection()) + .await?; + sqlx::query("BEGIN IMMEDIATE") + .execute(transaction.connection()) + .await?; + Ok::<_, sqlx::Error>(transaction) + }; + match tokio::time::timeout(LOCK_WAIT, begin).await { + Ok(Ok(transaction)) => Ok(transaction), + Ok(Err(error)) => Err(format!( + "Cannot start schema transaction: {error}. Another writer may hold the database; finish that operation and retry the migration. Lock acquisition waits at most 5 seconds." + )), + Err(_) => Err( + "Cannot start schema transaction within 5 seconds. Another writer or transaction may hold the database. Finish that operation, then retry the migration; its body has not run." + .to_string(), + ), + } + } + + pub fn connection(&mut self) -> &mut SqliteConnection { + self.connection + .as_mut() + .expect("schema transaction owns its connection until completion") + } + + pub async fn commit(mut self) -> Result<(), String> { + let validation = sqlx::query("PRAGMA foreign_key_check") + .fetch_optional(self.connection()) + .await; + let failure = match validation { + Ok(None) => None, + Ok(Some(row)) => { + let table: String = row.try_get("table").unwrap_or_default(); + Some(format!( + "Schema transaction would leave a foreign-key violation in table '{table}'. Restore the referenced rows or repair the migration before retrying; the transaction was rolled back." + )) + } + Err(error) => Some(format!( + "Cannot validate schema transaction foreign keys: {error}. Repair the schema before retrying; the transaction was rolled back." + )), + }; + if let Some(failure) = failure { + return match self.rollback().await { + Ok(()) => Err(failure), + Err(cleanup) => Err(format!("{failure} Cleanup also failed: {cleanup}")), + }; + } + if let Err(error) = sqlx::query("COMMIT").execute(self.connection()).await { + return match self.rollback().await { + Ok(()) => Err(format!("Failed to commit schema transaction: {error}")), + Err(cleanup) => Err(format!( + "Failed to commit schema transaction: {error}. Cleanup also failed: {cleanup}" + )), + }; + } + restore_connection(self.connection.take().expect("owned connection")).await + } + + pub async fn rollback(mut self) -> Result<(), String> { + if let Err(error) = sqlx::query("ROLLBACK").execute(self.connection()).await { + // Drop closes it if rollback fails; it must never reenter the pool. + let mut connection = self.connection.take().expect("owned connection"); + connection.close_on_drop(); + return Err(format!( + "Failed to roll back schema transaction: {error}. The connection was discarded." + )); + } + restore_connection(self.connection.take().expect("owned connection")).await + } +} + +impl Drop for SchemaTransaction { + fn drop(&mut self) { + let Some(mut connection) = self.connection.take() else { + return; + }; + // The worker processes rollback after any cancelled BEGIN/COMMIT await. + // The guard closes the connection if this cleanup task cannot finish. + match tokio::runtime::Handle::try_current() { + Ok(runtime) => { + let cleanup = CleanupConnection(Some(connection)); + runtime.spawn(async move { + let _ = restore_guarded_connection(cleanup, true).await; + }); + } + Err(_) => connection.close_on_drop(), + } + } +} + +async fn restore_connection(connection: PoolConnection) -> Result<(), String> { + restore_guarded_connection(CleanupConnection(Some(connection)), false).await +} + +async fn restore_guarded_connection( + mut cleanup: CleanupConnection, + rollback_first: bool, +) -> Result<(), String> { + let connection = cleanup.0.as_mut().expect("cleanup owns connection"); + let restore = async { + if rollback_first { + // BEGIN may not have completed, or COMMIT may already have completed, + // when the future was cancelled. A no-transaction error is harmless. + // Verification below refuses to pool a still-open transaction. + let _ = sqlx::query("ROLLBACK").execute(&mut **connection).await; + } + sqlx::query("PRAGMA foreign_keys = ON") + .execute(&mut **connection) + .await?; + let row = sqlx::query("PRAGMA foreign_keys") + .fetch_one(&mut **connection) + .await?; + row.try_get::(0) + }; + match restore.await { + Ok(1) => { + // Only a verified, restored connection can return to its pool. + drop(cleanup.0.take()); + Ok(()) + } + Ok(_) => Err("Foreign-key enforcement could not be restored after the schema transaction. The connection was discarded; close this database handle and reopen it before retrying.".to_string()), + Err(error) => Err(format!("Failed to restore foreign-key enforcement after schema transaction: {error}. The connection was discarded; close this database handle and reopen it before retrying.")), + } +} diff --git a/src/interpreter/mod.rs b/src/interpreter/mod.rs index b65bf0ed..cb86f7c1 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 @@ -1880,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 { @@ -2024,14 +2109,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(()), @@ -2049,6 +2139,32 @@ async fn terminate_foreground_child(child: &mut tokio::process::Child) -> Result /// `begin` (see [`IoClient::db_transactions`]). type SharedTransaction = Arc>>; +type TransactionRegistry = std::sync::Mutex>; + +/// Tie the registry reservation to the future executing its transaction block. +/// Dropping a suspended begin/body removes its own slot, so the connection's +/// rollback guard runs even while the interpreter and other handlers survive. +struct TransactionBlockGuard<'a> { + transactions: &'a TransactionRegistry, + key: (u64, String), + slot: SharedTransaction, +} + +impl Drop for TransactionBlockGuard<'_> { + fn drop(&mut self) { + let mut transactions = self + .transactions + .lock() + .unwrap_or_else(|error| error.into_inner()); + if transactions + .get(&self.key) + .is_some_and(|slot| Arc::ptr_eq(slot, &self.slot)) + { + transactions.remove(&self.key); + } + } +} + /// Serialize operations on one descriptor, including close. Keeping the file in /// this slot (rather than cloning it for each operation) also retains Tokio's /// in-flight buffers when a waiting operation is cancelled. @@ -2131,7 +2247,9 @@ pub struct IoClient { /// before the `begin` round-trip completes: two concurrent begins on one /// handle would otherwise both pass a `contains_key` check and the second /// would silently replace — and drop — the first transaction. - db_transactions: Mutex>, + // Registry operations never await while locked. A synchronous mutex lets + // transaction-block Drop reliably remove a cancelled reservation. + db_transactions: TransactionRegistry, /// Live outbound streaming response bodies, keyed by handle id /// ("httpstream1", ...). See [`StreamSlot`] / [`HttpStreamHandle`]. /// @@ -2732,7 +2850,7 @@ impl IoClient { next_process_id: Mutex::new(1), db_handles: Mutex::new(HashMap::new()), next_db_id: Mutex::new(1), - db_transactions: Mutex::new(HashMap::new()), + db_transactions: std::sync::Mutex::new(HashMap::new()), stream_handles: Arc::new(std::sync::Mutex::new(StreamRegistry::default())), next_stream_id: Mutex::new(1), #[cfg(test)] @@ -2780,7 +2898,7 @@ impl IoClient { if self .db_transactions .lock() - .await + .unwrap_or_else(|error| error.into_inner()) .keys() .any(|(_, handle)| handle == handle_id) { @@ -2802,14 +2920,22 @@ impl IoClient { } /// Open a transaction on `handle_id`, pinning one pooled connection to it. - async fn begin_transaction(&self, scope: u64, handle_id: &str) -> Result<(), String> { + async fn begin_transaction( + &self, + scope: u64, + handle_id: &str, + schema_changes: bool, + ) -> Result, String> { // Nesting would need savepoints, which are not implemented; say so // rather than quietly flattening the inner block into the outer one. // Reserve the handle and take the slot's lock *before* awaiting the // database, so a concurrent begin on the same handle loses the race // here rather than replacing a live transaction later. let slot: SharedTransaction = { - let mut transactions = self.db_transactions.lock().await; + let mut transactions = self + .db_transactions + .lock() + .unwrap_or_else(|error| error.into_inner()); if transactions.contains_key(&(scope, handle_id.to_string())) { return Err(format!( "A transaction is already open on database '{handle_id}'. Transaction \ @@ -2821,35 +2947,25 @@ impl IoClient { transactions.insert((scope, handle_id.to_string()), Arc::clone(&slot)); slot }; + let guard = TransactionBlockGuard { + transactions: &self.db_transactions, + key: (scope, handle_id.to_string()), + slot: Arc::clone(&slot), + }; // Held across the begin, so a statement that arrives meanwhile waits for // the transaction rather than seeing an empty slot and taking the pool. let mut open = slot.lock().await; - // The reservation must not outlive a failed begin, or the handle would - // be stuck refusing every later transaction. - let pool = match self.get_database(handle_id).await { - Ok(pool) => pool, - Err(err) => { - self.db_transactions - .lock() - .await - .remove(&(scope, handle_id.to_string())); - return Err(err); - } + // The block guard also removes the reservation on a failed or + // cancelled begin, before an actual transaction has been installed. + let pool = self.get_database(handle_id).await?; + let begun = if schema_changes { + database::begin_schema(&pool).await + } else { + database::begin(&pool).await }; - match database::begin(&pool).await { - Ok(tx) => { - *open = Some(tx); - Ok(()) - } - Err(err) => { - self.db_transactions - .lock() - .await - .remove(&(scope, handle_id.to_string())); - Err(err) - } - } + *open = Some(begun?); + Ok(guard) } /// Commit the open transaction on `handle_id`. @@ -2857,7 +2973,7 @@ impl IoClient { let slot = self .db_transactions .lock() - .await + .unwrap_or_else(|error| error.into_inner()) .remove(&(scope, handle_id.to_string())) .ok_or_else(|| format!("No transaction is open on database '{handle_id}'"))?; let tx = slot @@ -2876,7 +2992,7 @@ impl IoClient { let slot = self .db_transactions .lock() - .await + .unwrap_or_else(|error| error.into_inner()) .remove(&(scope, handle_id.to_string())); let tx = match slot { Some(slot) => slot.lock().await.take(), @@ -2902,7 +3018,7 @@ impl IoClient { let slot = self .db_transactions .lock() - .await + .unwrap_or_else(|error| error.into_inner()) .get(&(scope, handle_id.to_string())) .cloned(); if let Some(slot) = slot { @@ -2927,7 +3043,7 @@ impl IoClient { let slot = self .db_transactions .lock() - .await + .unwrap_or_else(|error| error.into_inner()) .get(&(scope, handle_id.to_string())) .cloned(); if let Some(slot) = slot { @@ -4414,6 +4530,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; @@ -4453,33 +4582,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; @@ -4588,27 +4724,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 @@ -4638,17 +4770,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; @@ -4657,6 +4791,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( @@ -4667,66 +4818,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 { @@ -4738,12 +4844,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) } @@ -4788,42 +4893,170 @@ 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), 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)?; + 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), + }; + 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) + } + } } /// Check if a process is still running @@ -4835,7 +5068,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. } } @@ -4955,6 +5188,7 @@ impl Interpreter { } Interpreter { + program_exit_code: Cell::new(0), global_env, current_count: RefCell::new(None), in_count_loop: RefCell::new(false), @@ -5045,6 +5279,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 @@ -6735,6 +6974,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 @@ -7143,6 +7383,7 @@ impl Interpreter { async fn execute_transaction_statement( &self, db: &Expression, + schema_changes: bool, body: &[Statement], line: usize, column: usize, @@ -7160,8 +7401,9 @@ impl Interpreter { } }; - self.io_client - .begin_transaction(self.tx_scope.get(), &handle) + let _transaction_guard = self + .io_client + .begin_transaction(self.tx_scope.get(), &handle, schema_changes) .await .map_err(|e| RuntimeError::new(e, line, column))?; @@ -8098,6 +8340,7 @@ impl Interpreter { Statement::ExitStatement { scope, + code, line, column, } => { @@ -8111,7 +8354,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)) + } } } @@ -8236,6 +8502,7 @@ impl Interpreter { } Statement::TransactionStatement { db, + schema_changes, body, line, column, @@ -8246,7 +8513,15 @@ impl Interpreter { // this arm's work behind a pointer keeps deeply nested programs // (and concurrent handlers, which recurse per request) clear of // the stack ceiling. - Box::pin(self.execute_transaction_statement(db, body, *line, *column, env)).await + Box::pin(self.execute_transaction_statement( + db, + *schema_changes, + body, + *line, + *column, + env, + )) + .await } Statement::ReadFileStatement { path, @@ -12965,6 +13240,7 @@ impl Interpreter { Statement::ExecuteCommandStatement { command, arguments, + directory, variable_name, use_shell, line, @@ -13013,11 +13289,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) => { @@ -13047,16 +13344,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 { @@ -13091,6 +13379,7 @@ impl Interpreter { Statement::SpawnProcessStatement { command, arguments, + directory, variable_name, use_shell, line, @@ -13139,11 +13428,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") @@ -13208,6 +13518,7 @@ impl Interpreter { } Statement::KillProcessStatement { process_id, + idempotent, line, column, } => { @@ -13227,20 +13538,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, } => { @@ -13259,24 +13575,47 @@ 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 + .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()); + 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), }; - RuntimeError::with_kind(e, *line, *column, kind) + 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 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 9ea0420a..803765d0 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, }, @@ -292,6 +293,9 @@ pub enum Statement { /// back if anything inside it fails (issue #664). TransactionStatement { db: Expression, + /// SQLite schema changes need enforcement disabled before BEGIN, then + /// an explicit foreign-key check before commit on the same connection. + schema_changes: bool, body: Vec, line: usize, column: usize, @@ -337,6 +341,7 @@ pub enum Statement { ExecuteCommandStatement { command: Expression, arguments: Option, + directory: Option, variable_name: Option, use_shell: bool, line: usize, @@ -355,6 +360,7 @@ pub enum Statement { SpawnProcessStatement { command: Expression, arguments: Option, + directory: Option, variable_name: String, use_shell: bool, line: usize, @@ -368,12 +374,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/database.rs b/src/parser/stmt/database.rs index 6a0429e3..99819afb 100644 --- a/src/parser/stmt/database.rs +++ b/src/parser/stmt/database.rs @@ -194,6 +194,36 @@ impl<'a> DatabaseParser<'a> for Parser<'a> { // runs on past the `:` that closes the header. let db = self.parse_primary_expression()?; + let schema_changes = if self + .cursor + .peek() + .is_some_and(|t| matches!(t.token, Token::KeywordFor)) + { + self.bump_sync(); + // Adjacent words are one identifier; these marker words remain + // available as ordinary names everywhere outside this header. + match self.cursor.peek() { + Some(token) if matches!(&token.token, Token::Identifier(words) if words == "schema changes") => + { + self.bump_sync(); + } + Some(token) => { + return Err(ParseError::from_token( + "Expected 'for schema changes' after the database handle".to_string(), + token, + )); + } + None => { + return Err(self.cursor.error( + "Expected 'for schema changes' after the database handle".to_string(), + )); + } + } + true + } else { + false + }; + self.expect_token( Token::Colon, "Expected ':' after the database in `in transaction on :`", @@ -234,6 +264,7 @@ impl<'a> DatabaseParser<'a> for Parser<'a> { Ok(Statement::TransactionStatement { db, + schema_changes, body, line: in_token.line, column: in_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/stdlib/core.rs b/src/stdlib/core.rs index 424ad6e4..cb5c6657 100644 --- a/src/stdlib/core.rs +++ b/src/stdlib/core.rs @@ -32,14 +32,48 @@ pub fn native_isnothing(args: Vec) -> Result { } } +/// Raise an application failure through the ordinary error-unwinding path. +/// Never stringify a non-text argument: it may contain confidential record data. +pub fn native_raise_error(args: Vec) -> Result { + check_arg_count("raise_error", &args, 1)?; + match &args[0] { + Value::Text(message) if !message.trim().is_empty() => { + Err(RuntimeError::new(message.to_string(), 0, 0)) + } + _ => Err(RuntimeError::new( + "raise_error expects a nonempty text message describing the operation, cause and corrective action. Do not include confidential values.".to_string(), + 0, + 0, + )), + } +} + +/// 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); env.define_native("type_of", native_typeof); env.define_native("is_nothing", native_isnothing); + env.define_native("raise_error", native_raise_error); // Text constants for natural-language string handling let _ = env.define("newline", Value::Text("\n".into())); diff --git a/src/stdlib/typechecker.rs b/src/stdlib/typechecker.rs index a6cbe5f6..0b58c3fe 100644 --- a/src/stdlib/typechecker.rs +++ b/src/stdlib/typechecker.rs @@ -63,8 +63,10 @@ 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(analyzer, &["raise_error"], vec![Type::Text], Type::Nothing); register( analyzer, &["typeof", "type_of"], diff --git a/src/typechecker/mod.rs b/src/typechecker/mod.rs index d304e5ae..9277b5df 100644 --- a/src/typechecker/mod.rs +++ b/src/typechecker/mod.rs @@ -2747,6 +2747,7 @@ impl TypeChecker { // Core functions "typeof" | "type_of" => Type::Text, "isnothing" | "is_nothing" => Type::Boolean, + "raise_error" => Type::Nothing, "print" | "sleep" | "foreach" => Type::Nothing, // Void functions // Math functions @@ -4016,7 +4017,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, @@ -5869,6 +5885,7 @@ impl TypeChecker { body, line: _line, column: _column, + .. } => { let db_type = self.infer_expression_type(db); if db_type != Type::Custom("Database".to_string()) @@ -5978,12 +5995,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(), @@ -6052,12 +6082,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(), @@ -6101,6 +6144,7 @@ impl TypeChecker { } Statement::KillProcessStatement { process_id, + idempotent: _, line: _line, column: _column, } => { @@ -6118,10 +6162,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(), @@ -6132,7 +6190,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/application-errors/library.wfl b/tests/fixtures/application-errors/library.wfl new file mode 100644 index 00000000..31b674c8 --- /dev/null +++ b/tests/fixtures/application-errors/library.wfl @@ -0,0 +1,7 @@ +define action called reject_record: + call raise_error with "Saving posts failed: title must be text. Supply a title." +end action + +define action called contextual_error with parameters cause_message: + call raise_error with ("Calculating the page count failed: " with cause_message) +end action diff --git a/tests/fixtures/application-errors/uncaught.wfl b/tests/fixtures/application-errors/uncaught.wfl new file mode 100644 index 00000000..f17d1237 --- /dev/null +++ b/tests/fixtures/application-errors/uncaught.wfl @@ -0,0 +1 @@ +call raise_error with "Saving the fixture failed: required title missing. Supply a title." 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/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 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/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 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 diff --git a/tests/fixtures/schema-transactions/cancellation-client.wfl b/tests/fixtures/schema-transactions/cancellation-client.wfl new file mode 100644 index 00000000..3991e341 --- /dev/null +++ b/tests/fixtures/schema-transactions/cancellation-client.wfl @@ -0,0 +1,3 @@ +store request_url as args[0] +open url at request_url and read content as reply +display reply diff --git a/tests/fixtures/schema-transactions/cancellation-server.wfl b/tests/fixtures/schema-transactions/cancellation-server.wfl new file mode 100644 index 00000000..88846b2c --- /dev/null +++ b/tests/fixtures/schema-transactions/cancellation-server.wfl @@ -0,0 +1,40 @@ +// One in-memory connection makes restoration and reuse directly observable. +open database at "sqlite::memory:" as conn +store created as execute conn with "CREATE TABLE migration_ledger (version TEXT)" +listen on port 0 as fixture_server +store listener_text as "" with fixture_server +store endpoint as "http://" with (substring of listener_text and 11 and ((length of listener_text) minus 11)) +display "READY " with endpoint +main loop concurrently: + wait for request comes in on fixture_server as incoming + store request_path as path of incoming + check if request_path is equal to "/cancel": + in transaction on conn for schema changes: + store added as execute conn with "INSERT INTO migration_ledger VALUES ('abandoned')" + store changed as execute conn with "CREATE TABLE abandoned_schema (id INTEGER)" + create file at "target/test-artifacts/schema-cancellation/started.txt" with "started" + wait for 20 seconds + end transaction + respond to incoming with "unexpected commit" + otherwise: + respond to incoming with "stopping" + close server fixture_server + break + end check +end loop +store rows as query conn with "SELECT * FROM migration_ledger" +store schema_rows as query conn with "SELECT name FROM sqlite_schema WHERE name = 'abandoned_schema'" +store fk_rows as query conn with "PRAGMA foreign_keys" +store fk_row as fk_rows[0] +in transaction on conn for schema changes: + store applied as execute conn with "INSERT INTO migration_ledger VALUES ('completed')" +end transaction +store complete_rows as query conn with "SELECT * FROM migration_ledger" +create map result: + "abandoned_rows" is length of rows + "abandoned_tables" is length of schema_rows + "enforcement" is fk_row["foreign_keys"] + "completed_rows" is length of complete_rows +end map +create file at "target/test-artifacts/schema-cancellation/recovered.json" with (stringify_json of result) +close database conn diff --git a/tests/fixtures/schema-transactions/exit.wfl b/tests/fixtures/schema-transactions/exit.wfl new file mode 100644 index 00000000..014d1d3d --- /dev/null +++ b/tests/fixtures/schema-transactions/exit.wfl @@ -0,0 +1,6 @@ +open database at "sqlite://target/test-artifacts/schema-lifecycle/lifecycle.db" as conn +in transaction on conn for schema changes: + store created as execute conn with "CREATE TABLE abandoned_schema (id INTEGER)" + store ledger_write as execute conn with "INSERT INTO migration_ledger VALUES ('interrupted')" + exit +end transaction diff --git a/tests/fixtures/schema-transactions/interrupted.wfl b/tests/fixtures/schema-transactions/interrupted.wfl new file mode 100644 index 00000000..38fa0630 --- /dev/null +++ b/tests/fixtures/schema-transactions/interrupted.wfl @@ -0,0 +1,9 @@ +open database at "sqlite://target/test-artifacts/schema-lifecycle/lifecycle.db" as conn +in transaction on conn for schema changes: + store created as execute conn with "CREATE TABLE abandoned_schema (id INTEGER)" + store ledger_write as execute conn with "INSERT INTO migration_ledger VALUES ('interrupted')" + open file at "target/test-artifacts/schema-lifecycle/ready.txt" for writing as ready_file + write content "ready" into ready_file + close file ready_file + wait for 20 seconds +end transaction 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,