diff --git a/.agents/docs/2026-11-06-declared-unit-continuation.md b/.agents/docs/2026-11-06-declared-unit-continuation.md new file mode 100644 index 0000000..e934fe8 --- /dev/null +++ b/.agents/docs/2026-11-06-declared-unit-continuation.md @@ -0,0 +1,34 @@ +# Declared units and live continuation identities + +This record describes the source group under review. The public dependency +versions have not yet been advanced to this ABI. + +`owned_unit::retire` seals admission and takes an explicit `program` from +`self` or `describe`. Both id and birth identify a still-live continuation. +A helper may outlive the publisher that started it, so its previous numeric +unit is not a sufficient return location. The build owner is an appropriate +continuation when it remains alive through retirement. An expired identity, +an inaccessible destination or a different native session yields unknown. +Migration does not authorize termination or admission into the continuation. + +`unit_observer` retains the original native observation reference. Its public +surface contains observation and lifetime operations only; native controls +and the native job are private. A compile-failure consumer verifies that it +cannot be supplied as a declared spawn unit or used to terminate a unit. +The C ABI observe/close restriction is a caller precondition on systems whose +unit is a numeric group, rather than a kernel-enforced read-only handle. + +The source tests use the existing directory-local blocking IPC transport. +The native consumers measure paired startup and listener start/stop samples, +retain outliers, and test sealed admission after both unknown and successful +retirement. Three native source builds are required; cross-compilation alone +does not qualify native lifetime behavior or cost. + +## 2026-11-06 dependency-group preparation + +The final source group names OpenKal 0.24, Linux 0.25, macOS 0.22, Windows +0.20 and Emscripten 0.13. OS 0.3.0 uses platform 0.24 for its hosted and +image consumers. Its public manifest is advanced only after the lower source +archives and index entries are available. Native source controls establish +API behaviour; the final complete package CI and ordinary public resolution +remain separate qualification gates. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37c6572..3de86d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,11 +13,9 @@ name: CI # run under node (checks/emscripten); a program that starts one # fails at LINK naming the openkal symbol (checks/absent) # -# The library is built with the released mcpp, 2026.11.2.1. The ok images -# (MCPP_PLATFORM) and checks/hosted (its manifest) select openkal-platform -# 0.23.0 and take their C library and C++ run-time from it (openkal 0.23's -# implementations among them; openkal-musl 0.27.0 embeds its allocator, which -# needs mcpp 2026.11 or later). The library +# The library is built with the released mcpp, 2026.11.5.1. The ok images +# (MCPP_PLATFORM) and checks/hosted select openkal-platform 0.24 and its +# OpenKal 0.24 implementation, musl 0.28 and C++ runtime 0.19 group. The library # itself needs 2026.10.27.1 or later, the first that selects a platform # (SPEC-013). A dispatch may name a branch of mcpp instead (`mcpp_ref'), which # the latest released mcpp builds and which is then used. @@ -37,7 +35,7 @@ env: MCPP_SOURCE_REF: ${{ github.event.inputs.mcpp_ref || vars.MCPP_SOURCE_REF || '' }} # 2026.11.3.1: the std module compiled with the platform's words (openkal-platform 0.23.0's # `-fcf-protection=full` refused every `import std` of an earlier mcpp's). - MCPP_INSTALL: ${{ (github.event.inputs.mcpp_ref || vars.MCPP_SOURCE_REF) && 'mcpp' || 'mcpp@2026.11.3.1' }} + MCPP_INSTALL: ${{ (github.event.inputs.mcpp_ref || vars.MCPP_SOURCE_REF) && 'mcpp' || 'mcpp@2026.11.5.1' }} XLINGS_VERSION: v2026.10.26.1 XLINGS_NON_INTERACTIVE: '1' @@ -113,7 +111,7 @@ jobs: run: | profile='${{ matrix.profile }}' mcpp test --profile "$profile" 2>&1 | tee tests.log - for name in platform_test fs_test process_test ipc_test dirs_terminal_test linux_test; do + for name in platform_test fs_test process_test ipc_test unit_test dirs_terminal_test linux_test; do grep -q "^$name \.\.\. ok" tests.log || { echo "::error::$name did not run or did not pass"; exit 1; } done @@ -211,7 +209,7 @@ jobs: run: | profile='${{ matrix.profile }}' mcpp test --profile "$profile" ${{ matrix.target }} 2>&1 | tee tests.log - for name in platform_test fs_test process_test ipc_test dirs_terminal_test linux_test library_test; do + for name in platform_test fs_test process_test ipc_test unit_test dirs_terminal_test linux_test library_test; do grep -q "^$name \.\.\. ok" tests.log || { echo "::error::$name did not run or did not pass"; exit 1; } done @@ -229,7 +227,7 @@ jobs: # openkal 0.23.0, which this release is written against, is the one # openkal-platform 0.23.3's version group names; mcpp 2026.11.3.1 and # later pin it for their openkal rows. - MCPP_PLATFORM: openkal-platform@0.23.3 + MCPP_PLATFORM: openkal-platform@0.24.0 defaults: run: shell: bash @@ -260,7 +258,7 @@ jobs: run: | set -euo pipefail mcpp test --target x86_64-openkal 2>&1 | tee tests.log - for name in platform_test fs_test process_test ipc_test dirs_terminal_test linux_test; do + for name in platform_test fs_test process_test ipc_test unit_test dirs_terminal_test linux_test; do grep -q "^$name \.\.\. ok" tests.log || { echo "::error::$name did not pass as an image"; exit 1; } done mcpp test --target aarch64-openkal --no-run @@ -281,7 +279,7 @@ jobs: # openkal 0.23.0, which this release is written against, is the one # openkal-platform 0.23.3's version group names; mcpp 2026.11.3.1 and # later pin it for their openkal rows. - MCPP_PLATFORM: openkal-platform@0.23.3 + MCPP_PLATFORM: openkal-platform@0.24.0 strategy: fail-fast: false matrix: @@ -399,7 +397,7 @@ jobs: run: | for t in tests/*_test.cpp; do n=$(basename "$t") - case "$n" in process_test.cpp|ipc_test.cpp) continue ;; esac + case "$n" in process_test.cpp|ipc_test.cpp|unit_test.cpp) continue ;; esac [ -f "checks/emscripten/tests/$n" ] || { echo "::error::checks/emscripten/tests has no $n"; exit 1; } done - name: Tests, under node diff --git a/.github/workflows/unit-lifecycle.yml b/.github/workflows/unit-lifecycle.yml new file mode 100644 index 0000000..f78bb66 --- /dev/null +++ b/.github/workflows/unit-lifecycle.yml @@ -0,0 +1,103 @@ +name: declared unit lifecycle +on: + pull_request: + workflow_dispatch: +concurrency: + group: unit-lifecycle-${{ github.ref }} + cancel-in-progress: true +env: + XLINGS_NON_INTERACTIVE: '1' + +jobs: + native: + name: declared unit and blocking ACK (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - { os: ubuntu-24.04, backend: openkal-linux, toolchain: gcc@16.1.0, revision: 225c545f8353570c277ebe79d0bb47b882826bca } + - { os: macos-15, backend: openkal-macos, toolchain: llvm@23.1.3, revision: 3b9686e9260f4a14169602c865751eea2af5f9e0 } + - { os: windows-2025, backend: openkal-windows, toolchain: msvc@system, revision: 9e8f5ab1c1202bf6a46929e82bec77e2667db730 } + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + # Immutable published OpenKal0.24/backend release commits; no prototype refs. + - uses: actions/checkout@v4 + with: + repository: mcpplibs/openkal + ref: c17bc255b9672325a726f0afa69f15f20c766b73 + path: deps/openkal + - uses: actions/checkout@v4 + with: + repository: mcpplibs/${{ matrix.backend }} + ref: ${{ matrix.revision }} + path: deps/backend + - uses: ilammy/msvc-dev-cmd@v1 + if: runner.os == 'Windows' + - name: Install the published build tool (Unix) + if: runner.os != 'Windows' + run: | + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.10.26.1 + echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" + - name: Install the published build tool (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + irm https://d2learn.org/xlings-install.ps1.txt | iex + "$env:USERPROFILE\.xlings\subos\current\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + - name: Install the actual native compiler + run: | + xlings update + xlings install mcpp@2026.11.5.1 -y -g + spec='${{ matrix.toolchain }}' + case "$spec" in + msvc@system) mcpp toolchain default msvc ;; + *) mcpp toolchain install "${spec%@*}" "${spec#*@}" + mcpp toolchain default "$spec" ;; + esac + - name: Select the coherent source group under review + env: + IMPLEMENTATION: ${{ matrix.backend }} + run: | + python - <<'PY' + from pathlib import Path + import os,re + root=Path('mcpp.toml') + text=root.read_text() + text=re.sub(r'^openkal = .*$', 'openkal = { path = "deps/openkal" }', text, flags=re.M) + text=re.sub(r'^'+re.escape(os.environ['IMPLEMENTATION'])+r' = .*$', os.environ['IMPLEMENTATION']+' = { path = "deps/backend" }', text, flags=re.M) + root.write_text(text) + backend=Path('deps/backend/mcpp.toml') + backend.write_text(re.sub(r'^openkal = .*$', 'openkal = { path = "../openkal" }', backend.read_text(), flags=re.M)) + PY + - name: Compile and run the real lifecycle/ACK consumer + run: | + set -o pipefail + mcpp test unit_test --verbose 2>&1 | tee unit-lifecycle.log + binary=$(find target -type f \( -name unit_test -o -name unit_test.exe \) | head -1) + test -n "$binary" + "$binary" | tee unit-cost.jsonl + - name: An observer cannot admit or terminate a program + run: | + set +e + (cd checks/observer-control && mcpp build) > observer-control.log 2>&1 + result=$? + set -e + test "$result" -ne 0 + python - <<'PYCONTROL' + from pathlib import Path + log=Path('observer-control.log').read_text() + assert 'unit_observer' in log and ('declared_unit' in log or 'owned_unit' in log) and 'terminate' in log, log + PYCONTROL + - uses: actions/upload-artifact@v4 + if: always() + with: + name: unit-lifecycle-${{ matrix.os }} + path: | + unit-lifecycle.log + unit-cost.jsonl + observer-control.log diff --git a/README.md b/README.md index 609fb62..469704b 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ with compile-time platform constants and a few platform modules. ```toml [dependencies] -os = { namespace = "mcpplibs", version = "0.2.9" } +os = { namespace = "mcpplibs", version = "0.3.0" } ``` ```cpp @@ -34,6 +34,29 @@ what is not a kernel question: the machine beneath the system (`machine`), the d loader (`library`: the C library's on Linux and macOS, kernel32's on Windows), and the platform modules. +## Declared process units + +Version 0.3.0 provides `process::owned_unit` and the observation-only +`process::unit_observer`. Create an owned unit before starting its external +members, retain `reference()` in the supervising invocation, and pass the unit +through `spawn_options::declared_unit`. The existing `process::spawn` performs +the start. The creating program is a member; `terminate()` can end it too. +`own_unit` and `declared_unit` are mutually exclusive. + +An observer opens that opaque reference and reports `unit_state::occupied`, +`empty` or `unknown`. Its type exposes neither admission nor termination. +Closing it terminates nothing. Missing native identity, including loss of all +native reference holders on a system that requires them, remains `unknown`. + +`owned_unit::retire(continuation)` seals admission before observing the end of +external members. The continuation identifies the surviving supervising +program outside the helper's unit, obtained from `self` or `describe` with +both identifier and birth retained. A dead, reused or +unreachable continuation does not authorize membership migration. The caller +can finish its bookkeeping after successful retirement. Retirement does not +grant control of the continuation's unit. Output recovery requires a positive +`empty` observation; leader termination alone is insufficient. + ## The program's C library A program over this package chooses its C library. It can be the system's own (glibc, @@ -87,7 +110,7 @@ dropped (0.2.8). | `mcpplibs.os.error` | `errc` (openkal's closed set of conditions), `error`, `result` | | `mcpplibs.os.env` | variables (names without case on Windows), `NAME=value` entries as overrides (`assignments`), arguments, the program's own image, lists of directories in the host's separator (`split_list`, `join_list`), PATH search with PATHEXT | | `mcpplibs.os.fs` | `write_atomic`, `publish` (no replace), `append`, `sync`, `caps`, `lock`, links, `set_executable`, `stamp_of`, `identity_of`, `owner_of`, `writable`, `locate`, `absolute`, `names_a_place`, `canonical`, a bounded `read`; what a volume does, measured (`measure`, `volume_type`), and the operations that follow it (`place`, `exchange`); names in the host's rules without the file system: `generic`, `native`, `extended`, `is_absolute`, `same`, `within` | -| `mcpplibs.os.process` | `spawn`, `run` (and with bounds: a hard bound, a grace, a drain, output limits), `exec`, a command line joined as it is for the command interpreter (`one_string`), pipes, both outputs on one pipe, output onto this program's error output, units and `end_unit`, bound lifetime, bounded waits and reads; `self`, `describe`, `alive`, `threads` | +| `mcpplibs.os.process` | `spawn`, `run` (and with bounds: a hard bound, a grace, a drain, output limits), `exec`, a command line joined as it is for the command interpreter (`one_string`), pipes, both outputs on one pipe, output onto this program's error output, units and `end_unit`, declared units, observation and retirement, bound lifetime, bounded waits and reads; `self`, `describe`, `alive`, `threads` | | `mcpplibs.os.interrupt` | `requested`, `wait`, `end_as_requested`, `end_as_output_closed` | | `mcpplibs.os.stdio` | the three streams as bytes; a bounded read of input; the output sent to the error stream for a while (`output_to_error`) | | `mcpplibs.os.terminal` | `interactive`, `size`, `sequences`, `color`, `raw_mode` (with or without the interrupting keystroke), `foreground` | diff --git a/checks/hosted/mcpp.toml b/checks/hosted/mcpp.toml index bae0a68..5f5f3ba 100644 --- a/checks/hosted/mcpp.toml +++ b/checks/hosted/mcpp.toml @@ -13,7 +13,7 @@ version = "0.1.0" os = { path = "../.." } [target.'cfg(any(linux, macos, windows))'] -platform = "openkal-platform@0.23.3" +platform = "openkal-platform@0.24.0" # The tests are the package's own, each reached by an include from # tests/.cpp here (a test program is a file of the package that runs it). diff --git a/checks/hosted/tests/unit_test.cpp b/checks/hosted/tests/unit_test.cpp new file mode 100644 index 0000000..8a922e6 --- /dev/null +++ b/checks/hosted/tests/unit_test.cpp @@ -0,0 +1,7 @@ +// Restate imports for the dependency scanner, as the other hosted wrappers do. +#include "../../../tests/check.hpp" +import std; +import mcpplibs.os.process; +import mcpplibs.os.ipc; +import mcpplibs.os.env; +#include "../../../tests/unit_test.cpp" diff --git a/checks/observer-control/mcpp.toml b/checks/observer-control/mcpp.toml new file mode 100644 index 0000000..4820191 --- /dev/null +++ b/checks/observer-control/mcpp.toml @@ -0,0 +1,7 @@ +# A negative compilation check: an observer supplies no unit control authority. +[package] +name = "observer-control-misuse" +version = "0.1.0" + +[dependencies] +os = { path = "../.." } diff --git a/checks/observer-control/src/main.cpp b/checks/observer-control/src/main.cpp new file mode 100644 index 0000000..04b0683 --- /dev/null +++ b/checks/observer-control/src/main.cpp @@ -0,0 +1,9 @@ +import std; +import mcpplibs.os.process; + +int main() { + auto observer = mcpplibs::os::process::unit_observer::open("opaque reference"); + mcpplibs::os::process::spawn_options options; + options.declared_unit = &*observer; // An observer cannot admit a program. + observer->terminate(); // It has no unit control operation. +} diff --git a/mcpp.toml b/mcpp.toml index f3b2cf3..ebc4091 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,7 +1,7 @@ [package] namespace = "mcpplibs" name = "os" -version = "0.2.9" +version = "0.3.0" description = "mcpplibs.os: a C++ operating-system library over openkal --- processes, files, environment, terminals, directories and local IPC, with compile-time platform constants and a few platform modules." license = "Apache-2.0" authors = ["mcpplibs"] @@ -12,23 +12,23 @@ repo = "https://github.com/mcpplibs/os" # own C and C++ runtime. A program that wants the standalone arrangement, or # the image arrangement of `-openkal`, selects it in its own manifest. [dependencies] -openkal = "0.23.0" +openkal = "0.24.0" [target.linux.dependencies] -openkal-linux = "0.24.1" +openkal-linux = "0.25.0" [target.macos.dependencies] -openkal-macos = "0.21.3" +openkal-macos = "0.22.0" [target.windows.dependencies] -openkal-windows = "0.19.0" +openkal-windows = "0.20.0" # Emscripten (`wasm32-emscripten`): openkal written above the C library, with # twelve of openkal's fifteen interfaces. `openkal.process', `openkal.exec' and # `openkal.space' are absent, so a program that starts one fails at link # naming the symbol; `openkal.task' is present in a `-pthread' link only. [target.'cfg(os = "emscripten")'.dependencies] -openkal-emscripten = "0.12.0" +openkal-emscripten = "0.13.0" [targets.os] kind = "lib" diff --git a/src/process.cpp b/src/process.cpp index fb7f88a..aec45ae 100644 --- a/src/process.cpp +++ b/src/process.cpp @@ -121,12 +121,77 @@ result> read_chunk_for(kal_stream s, bool open, std:: } // namespace +namespace { +unit_state unit_state_of(kal_u32 value) { + return value == kal::process::job_state::empty ? unit_state::empty + : value == kal::process::job_state::occupied ? unit_state::occupied : unit_state::unknown; +} +} +struct owned_unit::state { + kal_job job{}; + std::string reference; + bool sealed = false; + std::mutex admission; + ~state() { if (job.h) kal_process_job_close(job); } +}; +owned_unit::owned_unit() = default; +owned_unit::owned_unit(owned_unit&&) noexcept = default; +owned_unit& owned_unit::operator=(owned_unit&&) noexcept = default; +owned_unit::~owned_unit() = default; +result owned_unit::create() { + auto s = std::make_shared(); + std::string reference(512, '\0'); kal_uintptr used = 0; + const int rc = kal_process_job_create(&s->job,reference.data(),reference.size(),&used); + if (rc != kal_ok) return std::unexpected(from_kal(rc,"declaring a process unit")); + if (used == 0 || used > reference.size()) return std::unexpected(error{errc::io,"invalid process unit reference"}); + reference.resize(used); s->reference = std::move(reference); + owned_unit result; result.state_ = std::move(s); return result; +} +const std::string& owned_unit::reference() const { + static const std::string empty; + return state_ ? state_->reference : empty; +} +result owned_unit::retire(const program& continuation) { + if (!state_) return std::unexpected(error{errc::invalid,"retiring an empty process unit"}); + std::lock_guard guard(state_->admission); state_->sealed = true; + kal_u32 observed = kal::process::job_state::unknown; + const kal_program_identity anchor{continuation.id,continuation.started_ns}; + const int rc = kal_process_job_retire(state_->job,state_->reference.data(),state_->reference.size(),&anchor,&observed); + if (rc != kal_ok) return std::unexpected(from_kal(rc,"retiring a process unit")); + return unit_state_of(observed); +} +void owned_unit::terminate() { if (state_) kal_process_job_terminate(state_->job); } +result unit_observer::open(std::string_view reference) { + kal_job job{}; + const int rc = kal_process_job_open(reference.data(),reference.size(),&job); + if (rc != kal_ok) return std::unexpected(from_kal(rc,"opening a process unit reference")); + unit_observer result; result.job_ = job.h; result.reference_ = reference; return result; +} +unit_observer::unit_observer(unit_observer&& other) noexcept + : job_(std::exchange(other.job_,0)), reference_(std::move(other.reference_)) {} +unit_observer& unit_observer::operator=(unit_observer&& other) noexcept { + if (this != &other) { + if (job_) kal_process_job_close(kal_job{job_}); + job_ = std::exchange(other.job_,0); reference_ = std::move(other.reference_); + } + return *this; +} +unit_observer::~unit_observer() { if (job_) kal_process_job_close(kal_job{job_}); } +result unit_observer::observe() const { + if (!job_) return std::unexpected(error{errc::invalid,"observing an empty process unit reference"}); + kal_u32 observed = kal::process::job_state::unknown; + const int rc = kal_process_job_observe(kal_job{job_},reference_.data(),reference_.size(),&observed); + if (rc != kal_ok) return std::unexpected(from_kal(rc,"observing a process unit")); + return unit_state_of(observed); +} + struct process::state { kal_process handle{}; kal_stream input{}, output{}, error{}; bool input_open = false, output_open = false, error_open = false; kal_job job{}; bool has_job = false; + std::shared_ptr declared; std::optional ended; std::atomic bounded{ true }; std::mutex write_lock, wait_lock; @@ -336,9 +401,19 @@ result process::spawn(const spawn_options& o) { | (o.one_string ? kal::process::one_string_flag.bits : kal_uintptr{ 0 }) | (o.confine ? kal::process::confine_flag.bits : kal_uintptr{ 0 }) | preference | null_at }; + std::unique_lock admission; + if (o.declared_unit) { + if (o.own_unit || !o.declared_unit->state_) + return fail_streams(error{errc::invalid,"a declared process unit conflicts with own_unit or is empty"}); + s->declared = o.declared_unit->state_; + admission = std::unique_lock(s->declared->admission); + if (s->declared->sealed) + return fail_streams(error{errc::closed,"a retired process unit accepts no new program"}); + } kal_job job{}; const bool unit = o.own_unit && (props & kal::process::job.bits) != 0; if (unit) how.job = &job; + else if (s->declared) how.job = &s->declared->job; const kal_spawn_streams streams{ child_in, child_out, child_err }; const int rc = kal_process_spawn(&how, where->relative.data(), where->relative.size(), @@ -428,15 +503,17 @@ void process::kill() { if (!state_) return; std::lock_guard g(state_->wait_lock); if (state_->ended) return; - if (state_->has_job) kal_process_job_terminate(state_->job); + if (state_->declared) kal_process_job_terminate(state_->declared->job); + else if (state_->has_job) kal_process_job_terminate(state_->job); else kal_process_terminate(state_->handle); } bool process::reads_bounded() const noexcept { return !state_ || state_->bounded.load(); } void process::end_unit() { - if (!state_ || !state_->has_job) return; - kal_process_job_terminate(state_->job); + if (!state_) return; + if (state_->declared) kal_process_job_terminate(state_->declared->job); + else if (state_->has_job) kal_process_job_terminate(state_->job); } void process::detach() { diff --git a/src/process.cppm b/src/process.cppm index 8c5e639..e33f5ef 100644 --- a/src/process.cppm +++ b/src/process.cppm @@ -44,6 +44,61 @@ struct grant { // What a started program spends its machine's resources for (openkal 0.23). enum class resources { unstated, balanced, performance, economy }; +// A program identity, obtained from self/describe rather than inferred. +struct program { + std::uint64_t id = 0; + std::uint64_t started_ns = 0; + auto operator<=>(const program&) const = default; +}; + +// An observation is deliberately three-valued. A lost native reference is +// unknown, and cannot authorize output recovery or reuse of a compiler slot. +enum class unit_state { unknown, occupied, empty }; +class process; + +class unit_observer { +public: + static result open(std::string_view reference); + unit_observer() = default; + unit_observer(unit_observer&&) noexcept; + unit_observer& operator=(unit_observer&&) noexcept; + unit_observer(const unit_observer&) = delete; + unit_observer& operator=(const unit_observer&) = delete; + ~unit_observer(); + result observe() const; +private: + std::uintptr_t job_ = 0; + std::string reference_; +}; + +// The calling program declares its unit before starting work in it. Its +// reference can be retained by another owner before that work is admitted. +// Closing the object releases a reference and ends no member. +class owned_unit { +public: + static result create(); + owned_unit(); + owned_unit(owned_unit&&) noexcept; + owned_unit& operator=(owned_unit&&) noexcept; + owned_unit(const owned_unit&) = delete; + owned_unit& operator=(const owned_unit&) = delete; + ~owned_unit(); + const std::string& reference() const; + // Seals admission, then asks whether every external member has ended. + // Occupied/unknown can be retried. Future spawns into this unit fail. + // The caller remains alive and may finish its own bookkeeping in the + // continuation's unit. Its full identity is checked; no stale group is used. + // Dead, reused or inaccessible continuations yield unknown. This conveys + // no admission or termination right over the continuation's unit. + result retire(const program& continuation); + // Ends every member outright, including this program when it is a member. + void terminate(); +private: + friend class process; + struct state; + std::shared_ptr state_; +}; + struct spawn_options { // An absolute name, or a bare one looked for on PATH (`env::find_executable'). std::filesystem::path program; @@ -77,6 +132,10 @@ struct spawn_options { // errc::not_supported where the system cannot hold it. bool confine = false; resources preference = resources::unstated; + // Explicitly declared unit, mutually exclusive with own_unit. It must not + // have begun retirement. end_unit/kill then end this entire unit, including + // its owner if the owner is a member. + owned_unit* declared_unit = nullptr; }; // How a program ended: the status it returned, or, ended by its environment @@ -189,11 +248,7 @@ result exec(spawn_options options); // --- programs this one did not start (openkal 0.17) ------------------------ // A program, named so that a later one given the same identifier is another. -struct program { - std::uint64_t id = 0; - std::uint64_t started_ns = 0; - auto operator<=>(const program&) const = default; -}; + struct program_info { program who; diff --git a/tests/unit_test.cpp b/tests/unit_test.cpp new file mode 100644 index 0000000..776c17a --- /dev/null +++ b/tests/unit_test.cpp @@ -0,0 +1,150 @@ +// Declared units and a retained observer over the existing directory IPC. +#include "check.hpp" +import std; +import mcpplibs.os.process; +import mcpplibs.os.ipc; +import mcpplibs.os.env; + +namespace process = mcpplibs::os::process; +namespace ipc = mcpplibs::os::ipc; +using clock_type = std::chrono::steady_clock; +using namespace std::chrono_literals; + +static std::optional line(ipc::connection& c, std::chrono::milliseconds bound) { + const auto until = clock_type::now() + bound; + std::string out; + while (out.size() <= 1024) { + const auto left = std::chrono::duration_cast(until - clock_type::now()); + if (left <= 0ms) return {}; + auto read = c.read_for(left, 1024); + if (!read || !*read || (**read).empty()) return {}; + out += **read; + if (const auto end = out.find('\n'); end != std::string::npos) { + if (end + 1 != out.size()) return {}; + out.resize(end); return out; + } + } + return {}; +} + +int main(int argc, char** argv) { + const auto command = mcpplibs::os::env::self_command(); + if (command.empty()) return 41; + auto self = [&](std::vector args) { + process::spawn_options options; + options.program = std::filesystem::u8path(command.front()); + options.args.assign(command.begin() + 1, command.end()); + options.args.insert(options.args.end(), args.begin(), args.end()); + return options; + }; + if (argc > 2 && std::string_view(argv[1]) == "writer") { + std::ofstream out(argv[2]); out << "declared-unit writer\n"; return out ? 0 : 40; + } + if (argc > 7 && std::string_view(argv[1]) == "helper") { + auto unit = process::owned_unit::create(); + if (!unit) return 30; + const std::filesystem::path output(argv[4]); + const process::program continuation{std::stoull(argv[6]),std::stoull(argv[7])}; + if (std::string_view(argv[5]) != "none") { + auto c = ipc::connection::connect(argv[2],argv[3]); + if (!c || !c->write(unit->reference() + "\n")) return 31; + const auto ack = line(*c, std::string_view(argv[5]) == "timeout" ? 50ms : 2s); + if (!ack || *ack != "ACK") return 31; + } + { + auto options = self({"writer", output.string()}); + options.out = process::stdio::null; + options.err = process::stdio::null; + options.declared_unit = &*unit; + auto writer = process::process::spawn(options); + if (!writer) return 32; + const auto status = writer->wait(); + if (!status || !status->success()) return 33; + } + auto wrong=continuation;++wrong.started_ns; + auto refused=unit->retire(wrong); + if(!refused||*refused!=process::unit_state::unknown)return 38; + const auto limit = clock_type::now() + 2s; + for (;;) { + auto retired = unit->retire(continuation); + if (!retired) return 34; + if (*retired == process::unit_state::empty) break; + if (clock_type::now() >= limit) return 35; + std::this_thread::sleep_for(1ms); + } + if (auto again = unit->retire(continuation); !again || *again != process::unit_state::empty) return 36; + auto forbidden_options = self({"writer", output.string()+".forbidden"}); + forbidden_options.declared_unit = &*unit; + auto forbidden = process::process::spawn(forbidden_options); + if (forbidden || forbidden.error().code != mcpplibs::os::errc::closed) return 37; + return 0; + } + + const auto identity = process::self(); + if (!identity) return 1; + const auto root = std::filesystem::temp_directory_path() / std::format("os-unit-{}",identity->id); + std::filesystem::create_directories(root); + struct Remove { std::filesystem::path path; ~Remove() { std::error_code ec; std::filesystem::remove_all(path,ec); } } remove{root}; + auto attempt = [&](std::string_view mode, int repeat) { + const auto name = std::format("unit-{}-{}",mode,repeat); + const auto output = root / (name + ".object"); + std::optional listener; + std::optional observer; + bool server_ok = true; + std::jthread server; + const auto started = clock_type::now(); + if (mode != "none") { + auto listening = ipc::listener::listen(root,name); + if (!listening) { check(false,"a unit registration listener is opened"); return; } + listener.emplace(std::move(*listening)); + server = std::jthread([&] { + auto accepted = listener->accept_for(2s); + if (!accepted || !*accepted) { server_ok = false; return; } + auto reference = line(**accepted,2s); + if (!reference) { server_ok = false; return; } + auto retained = process::unit_observer::open(*reference); + if (!retained) { server_ok = false; return; } + observer.emplace(std::move(*retained)); + if (mode == "ack") server_ok = bool((**accepted).write("ACK\n")); + if (mode == "timeout") std::this_thread::sleep_for(100ms); + }); + } + int exit = -1; + { + auto options = self({"helper", root.string(), name, output.string(), std::string(mode), + std::to_string(identity->id), std::to_string(identity->started_ns)}); + options.in = process::stdio::null; + options.out = process::stdio::null; + options.err = process::stdio::null; + auto helper = process::process::spawn(options); + if (helper) { auto status = helper->wait(); if (status) exit=status->shell_code(); } + } + if (server.joinable()) server.join(); + check(server_ok,"the original unit is retained before acknowledgement"); + const bool rejected = mode == "closed" || mode == "timeout"; + check(exit == (rejected ? 31 : 0),"a compiler starts only after successful registration"); + check(std::filesystem::exists(output) != rejected,"a refused acknowledgement produces no output"); + check(!std::filesystem::exists(output.string()+".forbidden"),"retirement prevents further admission"); + if (observer) { + auto state = observer->observe(); + check(state && *state == process::unit_state::empty,"the original unit is empty after helper completion"); + } + const auto elapsed = std::chrono::duration_cast(clock_type::now()-started).count(); + std::printf("{\"unit_start_mode\":\"%.*s\",\"elapsed_ns\":%lld}\n",int(mode.size()),mode.data(),static_cast(elapsed)); + }; + for (int repeat = 0; repeat < 8; ++repeat) { attempt("none",repeat); attempt("ack",repeat); } + attempt("closed",0); attempt("timeout",0); + for (int repeat = 0; repeat < 8; ++repeat) { + const auto name=std::format("idle-{}",repeat); + const auto started=clock_type::now(); + auto listener=ipc::listener::listen(root,name); + check(bool(listener),"a no-op listener opens"); + if (!listener) continue; + std::jthread server([&] { auto peer=listener->accept_for(2s); }); + { auto peer=ipc::connection::connect(root,name); check(bool(peer),"closure wakes the listener without a polling delay"); } + server.join(); listener->close(); + const auto elapsed=std::chrono::duration_cast(clock_type::now()-started).count(); + std::printf("{\"unit_listener_noop_ns\":%lld}\n",static_cast(elapsed)); + } + return g_failures; +}