Skip to content

feat: add per-service runtime monitor core - #412

Merged
clvsh merged 6 commits into
mainfrom
feat/service-monitor-core
Oct 11, 2026
Merged

clvsh merged 6 commits into
mainfrom
feat/service-monitor-core

Conversation

@clvsh

@clvsh clvsh commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

PR 3 of the per-service monitor stack. It adds the monitor engine, the hidden pv monitor:run and pv monitor:gate commands, and process-level contract tests. Nothing in production starts a monitor yet: PR 4 switches the daemon over, and PR 5 removes legacy adoption.

How a monitor runs a runtime

controller (tests now, the daemon in PR 4)
  └─ pv monitor:run    stdin = locked reservation, stdout = startup socket
       setsid()
       └─ pv monitor:gate    own process group; stdin = gate socket; stdout/stderr = runtime log
            exec → the runtime keeps the gate's pid and birth identity
  • Reservation. The controller locks run/m/<12 hex>.lock and hands the same locked file to the monitor as stdin, so the lock is held with no gap until the monitor dies. Release and recovery use that lock as proof that the monitor has exited.
  • Startup. The monitor writes run/m/<12 hex>/monitor.json (no secrets) before the gate may exec. Exec is confirmed by the kqueue exec event or the gate's own error report; the socket closing alone never counts.
  • Exit. The monitor learns the exit status from kqueue without reaping. The zombie leader pins the process group until cleanup is proven, and the leader is reaped exactly once.
  • Stop. One stop executor per instance: graceful signal, grace period, SIGKILL. A repeated stop never resets the deadline, and a client disconnecting never cancels it. When the runtime exits on its own, the monitor runs the start request's fallback stop on whatever is left in the group.
  • Lifeline (tests only). When the test process dies, the monitor stops the runtime and releases itself, so a failed test leaves nothing running.

Protocol v1

One request per connection over bounded NDJSON (protocol::transport, 64 KiB). The monitor checks the peer's UID. The controller checks the peer pid, start identity and boot against monitor.json, and both sides check version and instance. Unknown fields are ignored.

op reply
state subject, monitor and runtime pids, first stop, exit (code or signal), cleanup (pending, complete or unproven), log_rotation_error
stop {signal, grace_ms} accepted immediately
release accepted only once the exit is known and cleanup is complete; otherwise not_releasable

Error kinds: unsupported_version, instance_mismatch, not_releasable, invalid_request and startup_failed. The frozen replies are snapshotted in monitor__monitor_requests_follow_the_frozen_contract.snap.

The controller API, which PR 4 reuses, is start_monitor, monitor_state, stop_monitor, release_monitor and recover_monitor. Recovery refuses a live monitor because the reservation is held. A record from a previous boot is removed without sending any signal. A runtime with its exact recorded identity is stopped. A changed identity is refused.

Log rotation: the monitor copies and truncates the retained append descriptor once the log passes 10 MiB (checked every 60 s), and keeps five <stem>-YYYYMMDD-HHMMSS-mmm.log archives that pv logs already lists. Output written between the copy and the truncation is lost; that window is accepted.

Two macOS behaviors found while testing

  1. killpg answers EPERM, not ESRCH, when no member can take a signal. That covers a group whose only members are zombies, and also members that are still exiting.
    • Every group the monitor stops ends up zombie-only.
    • A killed runtime's children stay "exiting" for a while: a 1.5 GiB process answered EPERM for more than 10,000 polls during its teardown.
    • signal_process_group now treats EPERM as "signalled" and lets the group-exit wait that every caller runs decide the stop.
    • This is the one behavior change in supervisor.rs; everything else there is visibility, plus a closure-based stop_process_group that the old by-pid stop now calls.
    • Fixed in f9a9f59, which moved down from feat(daemon): run production runtimes through monitors #414. The first version of this change accepted EPERM only once the group had already exited. A monitor's fallback stop could then start while a child was still tearing down, fail, and mark cleanup unproven, blocking the runtime until a reboot. monitor_cleans_up_a_killed_group_whose_member_is_still_exiting failed 5/5 with that version and passes 20/20 now.
  2. The process identity lookup skips a process that is mid-exit, while the group check still counts it as live. Recovery therefore gives a leader with no identity up to 1 s to finish exiting before refusing.

Changes from the approved plan

  • No separate version op. Every message carries version.
  • Moved to PR 4: push events (watch), explicit stop on cancellation, directory listing, the Postgres evidence policy, real-artifact tests, choosing the installed release executable, and the lifeline during readiness and daemon restarts.
  • The gate never holds the reservation. Recovery matches the gate by pid and birth identity.
  • Test hooks come from pv-monitor-hooks.json in the test's PV home, not a PV_MONITOR_TEST_HOOKS variable. The controller already sets the monitor's HOME, so no environment handling is needed. Production pv always uses the defaults.
  • Release proves the monitor's death by the reservation alone. Only the monitor holds that lock, so a separate identity check adds nothing.
  • Rotation is in the engine commit, so every commit builds. It is about 200 lines in monitor/rotation.rs if you'd rather review it on its own.
  • Size is about 3.4k lines, roughly 1.7k of them tests and snapshots, against the plan's 2.2k estimate.

Footprint

Measured on a release build, idle, with /bin/sleep as the runtime:

Spawn until exec confirmed 21 ms
RSS 9.2 MiB (top physical footprint: 3.2 MB)
Threads 2 (runtime and lifeline reader)
Idle wakeups 0 over a 10 s sample; rotation checks once a minute

Test plan

  • cargo nextest run --profile ci --workspace --all-features --locked: 1654 passed, also with a stress run going alongside. After f9a9f59: 1655 passed.
  • cargo nextest run -p daemon --profile ci -E 'binary(monitor) | test(process_watch)' --stress-count 20 under load: 20/20.
  • cargo clippy --workspace --all-targets --all-features --locked -- -D warnings, cargo fmt --all --check, cargo shear.
  • After the runs, no monitor or runtime processes were left from this worktree.
  • Linux and Windows compile in CI's portability job. The local Linux check stops at ring, which needs a cross C compiler.

Tests added:

  • crates/daemon/tests/monitor.rs (18 contract tests):
    • the full lifecycle, with exec continuity and a record snapshot
    • the frozen contract
    • a missing command
    • an exit while no client is connected
    • stop after the client disconnects, and repeated stops
    • a leader exiting while its group lives on
    • a killed group whose member is still exiting, using pv-fake's new descendant_resident_mib setting
    • an unproven cleanup
    • monitor death at each startup point
    • recovery of a killed or SIGSTOPped monitor
    • changed identity and a previous boot
    • reservation continuity
    • a closed lifeline
    • an overlong socket path
    • a symlinked log
    • a held rotation
  • Rotation unit tests.
  • A pv logs follow-across-truncation test.
  • A capability mapping test.
  • A real-pv smoke test in it/cli.rs.
  • A platform test of the kqueue exec-then-exit watch without reaping.

Summary by CodeRabbit

  • New Features
    • Added macOS runtime monitoring to start, inspect, stop, and release managed processes, with recovery and cleanup when a monitor exits unexpectedly.
    • Added automatic log rotation that archives older logs while retaining the active log.
  • Bug Fixes
    • Improved process cleanup when a process group has already exited, and preserved log output across truncation.

clvsh added 3 commits October 9, 2026 20:52
…open

ProcessExitWatch::with_exec reports a watched process's exec as well as
its exit, without reaping it, and exposes its kqueue descriptor so a
Tokio AsyncFd can await it.

PvPaths gains run/m/<12 hex> monitor directories and their reservation
locks, sharing the 12-digit SHA-256 digest worker admin sockets use.
MonitorReservation takes the exclusive lock, hands the same locked file
to a child as stdin, and lets that child adopt and verify it.
open_private_log_file opens a runtime log without following symlinks
and checks owner, type and link count on the opened descriptor.
A monitor process owns one runtime for its whole life. It starts the
runtime through a gate that execs only once the monitor has written its
recovery record, so the runtime keeps the gate's pid and birth identity.
The monitor learns the exit status from kqueue without reaping, so the
zombie leader pins the process group until cleanup is proven, and it
reaps the leader exactly once after that.

Controllers use start_monitor, monitor_state, stop_monitor,
release_monitor and recover_monitor over a bounded NDJSON protocol
(version 1) that checks the peer and the instance on both sides.
Release removes records only once the reservation proves the monitor
exited. Recovery stops a dead monitor's runtime only by its exact
identity from this boot.

The monitor rotates its runtime's log by copy and truncate on the
retained descriptor and keeps five archives that `pv logs` lists.

Signalling a group whose only members are zombies now counts as done,
because macOS answers EPERM instead of ESRCH for one.

Nothing in production starts a monitor yet.
`pv monitor:run` and `pv monitor:gate` run the monitor engine. Both need
process containment and skip runtime lifecycle admission, which a
monitor holding it for its whole life would keep `daemon:disable` from
taking. A smoke test drives the real binary through start, stop and
release.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-10T17:46:00.871807Z f9a9f59 New commits
🔒 Security Review ✅ Completed 2026-10-10T00:57:18.407414Z 259602b PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@coderabbitai

coderabbitai Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

📝 Walkthrough
📝 Walkthrough

Walkthrough

The daemon adds a macOS runtime monitor that starts and supervises runtimes, accepts state, stop, and release requests, recovers monitor records, and rotates logs. Supporting changes add process-event observation, monitor reservations, secure log handling, CLI commands, and integration tests.

Changes

Runtime Monitor

Layer / File(s) Summary
Monitor contracts and reservation storage
crates/daemon/src/monitor.rs, crates/daemon/src/error.rs, crates/daemon/src/lib.rs, crates/daemon/src/supervisor.rs, crates/state/src/paths.rs, crates/state/src/fs.rs, crates/state/src/update_lock.rs, crates/state/src/lib.rs
The daemon defines monitor configuration, state, and error types. The state crate adds monitor paths, secure log-file opening, and reservation acquisition and adoption.
Process event observation
crates/platform/src/process.rs, crates/platform/src/process/macos.rs, crates/platform/src/process/unsupported.rs, crates/platform/src/lib.rs, crates/platform/tests/process_identity.rs
The platform crate adds process exec and exit event observation on macOS, with unsupported-platform methods. Tests check event reporting, process identity, and reaping.
Monitor startup, control, and cleanup
crates/daemon/src/monitor/macos.rs, crates/daemon/src/monitor/rotation.rs, crates/daemon/tests/monitor.rs
The monitor starts runtimes through a gate, handles controller requests and recovery, tracks cleanup, and rotates logs. Tests cover lifecycle, failure, and recovery cases.
CLI entry points and test support
Cargo.toml, crates/cli/src/args.rs, crates/cli/src/commands/daemon.rs, crates/cli/src/commands/mod.rs, crates/cli/src/commands/logs.rs, crates/daemon/examples/pv-monitor.rs, crates/pv-fake/src/*, crates/pv-fake/tests/contracts.rs, it/cli.rs
The CLI adds hidden monitor commands and dispatches them to daemon entry points. The example binary loads optional hooks. Supporting changes add fake-runtime controls and integration tests, including log-follow coverage for truncation.

Priority: ➖ Normal

Estimated code review effort: 4 (Complex) | ~60 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant Controller
  participant Monitor
  participant Gate
  participant Runtime
  Controller->>Monitor: start monitor with runtime configuration
  Monitor->>Gate: launch gate with runtime configuration
  Gate->>Runtime: exec permitted runtime command
  Monitor-->>Controller: return state after exec confirmation
Loading


Merge Risk: ⚪ Minimal · up to f9a9f

The stop command no longer has the deadline that could prematurely report healthy cleanup as unavailable. The remaining concern is targeted regression-test coverage, not a demonstrated runtime failure.

Security Architecture Review

Security architecture risk: 🔵 Low · up to f9a9f

The new lifecycle has explicit ownership, identity, and cleanup safeguards, and normal daemon operation does not yet start monitors. No introduced security weakness was established in the inspected paths. Risk remains low rather than minimal because platform-specific failure behavior and broader coverage remain incomplete.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The inspected control authority is local to the operating-system user and the recorded runtime process group. A same-UID client with access to the instance can request stopping that runtime; the protocol does not distinguish separate tenants sharing a UID. No new cross-user execution authority was established by these paths.

Trust Boundaries and Controls

  • observed — The server rejects other-UID peers and mismatched protocol versions or instances. Controllers additionally authenticate the monitor's peer PID, start identity, and boot session before sending requests. Recovery requires reservation ownership and rejects a changed runtime identity before signaling it.

Resilience and Maintainability Implications

  • observed — The shared macOS signal helper newly tolerates PermissionDenied, but the inspected stop paths still require group-exit proof and retain escalation ownership checks. This broadens error classification without establishing a cleanup-control bypass. The resident-memory test checks completed cleanup, not whether PermissionDenied actually occurred.

Pre-merge checks | Passed 4 | Failed 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 46.63% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 178 functions across 27 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly summarizes the primary change: adding the per-service runtime monitor core. It is concise and directly related to the changeset.

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR









🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR



  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@codspeed

codspeed Bot commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 7 untouched benchmarks


Comparing feat/service-monitor-core (f9a9f59) with main (3c26bcc)

Open in CodSpeed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Quiet mode is enabled, so only the most important comments were posted inline. Other review comments are grouped below.

🟡 Other comments (1)
crates/daemon/src/monitor/macos.rs (1)

208-209: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Base the stop_monitor deadline on the stop that the monitor actually runs.

stop_monitor sets its deadline from the caller's stop.grace() plus STOP_MARGIN. The monitor does not always use that stop:

  • Command::Stop runs start_cleanup. If an executor already runs, start_cleanup returns early. This happens after a self-exit with fallback_stop or after an earlier stop request.
  • The test monitor_stop_outlives_its_client_and_keeps_its_first_deadline confirms that the first deadline wins.

Example: the runtime exits by itself with a 10 s fallback_stop and a surviving child. A controller then calls stop_monitor with a 1 s grace. After about 6 s, stop_monitor returns MonitorUnavailable("the runtime's cleanup did not finish"). The cleanup is still running and later reaches Complete. The caller then treats a healthy monitor as failed.

Take the deadline from the longest grace that can apply. One option is to make the monitor report its active stop in MonitorState and extend the deadline from that value. A simpler option is to use the maximum of the requested grace and record.fallback_stop.grace(), plus the margin and the 1 s kill wait.

Proposed fix
-    request(paths, subject, Operation::Stop(stop)).await?;
-    let deadline = Instant::now().checked_add(stop.grace().saturating_add(STOP_MARGIN));
+    let (record, _accepted) = request(paths, subject, Operation::Stop(stop)).await?;
+    // The monitor keeps the first stop, or its fallback, if a cleanup already runs.
+    let grace = stop.grace().max(record.fallback_stop.grace());
+    let deadline = Instant::now().checked_add(grace.saturating_add(STOP_MARGIN));

This fix does not cover an earlier controller stop with a longer grace. That case still needs the active stop in MonitorState.


ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: QUIET
  • Plan: Advanced
  • Run ID: 3ca5b064-0c50-474b-80f8-70ffd2889768
📥 Commits

Reviewing files that changed from the base of the PR and between 3c26bcc and 259602b.

⛔ Files ignored due to path filters (7)
  • Cargo.lock is excluded by !**/*.lock
  • crates/daemon/tests/snapshots/monitor__monitor_requests_follow_the_frozen_contract.snap is excluded by !**/*.snap
  • crates/daemon/tests/snapshots/monitor__monitor_runs_a_runtime_through_stop_and_release.snap is excluded by !**/*.snap
  • crates/daemon/tests/snapshots/monitor__monitor_start_reports_a_missing_command_and_leaves_no_instance.snap is excluded by !**/*.snap
  • it/snapshots/cli__completions_generate_bash_script.snap is excluded by !**/*.snap
  • it/snapshots/cli__completions_generate_fish_script.snap is excluded by !**/*.snap
  • it/snapshots/cli__completions_generate_zsh_script.snap is excluded by !**/*.snap
📒 Files selected for processing (25)
  • Cargo.toml
  • crates/cli/src/args.rs
  • crates/cli/src/commands/daemon.rs
  • crates/cli/src/commands/logs.rs
  • crates/cli/src/commands/mod.rs
  • crates/daemon/examples/pv-monitor.rs
  • crates/daemon/src/error.rs
  • crates/daemon/src/lib.rs
  • crates/daemon/src/monitor.rs
  • crates/daemon/src/monitor/macos.rs
  • crates/daemon/src/monitor/rotation.rs
  • crates/daemon/src/supervisor.rs
  • crates/daemon/tests/monitor.rs
  • crates/platform/src/lib.rs
  • crates/platform/src/process.rs
  • crates/platform/src/process/macos.rs
  • crates/platform/src/process/unsupported.rs
  • crates/platform/tests/process_identity.rs
  • crates/pv-fake/src/install.rs
  • crates/pv-fake/src/lib.rs
  • crates/state/src/fs.rs
  • crates/state/src/lib.rs
  • crates/state/src/paths.rs
  • crates/state/src/update_lock.rs
  • it/cli.rs

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 9 remain after this review.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

Two gaps need attention: test-process death during early startup pauses can leak monitor fixtures indefinitely, and normal controller requests omit reply-version validation.

Reviewed changes Full review of the per-service monitor core, its platform/state primitives, hidden CLI entry points, and tests; production daemon integration remains intentionally deferred.

  • Runtime lifecycle: Record-before-exec gate startup, kqueue exec/exit observation, unreaped-leader group cleanup, and first-stop deadline ownership.
  • Controller protocol: Authenticated state/stop/release requests, reservation-based release, and identity-checked recovery after monitor death.
  • State and logging: Per-subject monitor paths and inherited reservations, private log descriptors, copy/truncate rotation, and archive retention.
  • CLI and tests: Hidden command routing, platform capability mapping, process-contract fixtures and snapshots, and log-follow regression coverage.

Validation: both targeted portable CLI tests and cargo fmt --all --check passed. Used cargo test because cargo nextest is unavailable; macOS process-contract tests could not run in this Linux environment.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using gpt-6.1-sol | 𝕏

Comment thread crates/daemon/src/monitor/macos.rs
Comment thread crates/daemon/src/monitor/macos.rs Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 259602b0cf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/daemon/src/monitor/macos.rs Outdated
stop: MonitorStop,
) -> Result<MonitorState, DaemonError> {
request(paths, subject, Operation::Stop(stop)).await?;
let deadline = Instant::now().checked_add(stop.grace().saturating_add(STOP_MARGIN));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use the first stop policy for polling

When a stop is already underway, the monitor deliberately preserves only the first request and its deadline, but this macOS controller computes the polling timeout from the new caller's stop. If an initial caller requests a 60-second grace and disconnects, then a retry requests zero grace, the retry reports MonitorUnavailable after roughly five seconds even though cleanup is correctly proceeding under the accepted 60-second policy; derive the timeout from the monitor's recorded first stop instead.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 049fcff by removing the controller-side deadline instead of recomputing it. Whichever request started the monitor's stop (the first stop, or the fallback after the runtime exited on its own), it ends within that stop's grace period plus the 1 s SIGKILL wait. Each state request also has its own 5 s timeout, which covers a monitor that stops answering. A shorter retry now waits for the stop that is actually running. The repeated-stop test now calls stop_monitor for its second stop.

Comment thread crates/daemon/src/monitor/macos.rs Outdated
tokio::select! {
events = next_optional_events(&mut self.watch) => self.observe(events?, hooks),
accepted = self.listener.accept() => {
if let Ok((stream, _address)) = accepted {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Back off persistent accept failures

On macOS, every listener error is silently discarded. A persistent error such as EMFILE or ENFILE can leave this select arm repeatedly ready, causing the monitor to busy-loop while clients merely time out and the underlying failure is never recorded; handle terminal errors or retain the error and back off retryable failures.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 049fcff: an accept error now waits 50 ms before retrying, the same ACCEPT_ERROR_BACKOFF the daemon's IPC and DNS servers use.

Comment thread crates/daemon/src/monitor/macos.rs Outdated
Comment on lines +368 to +370
if reply.instance != record.instance {
return Err(unavailable(subject, "the reply came from another instance"));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject replies from another protocol version

When a monitor from another application release returns a reply whose schema still deserializes, this path validates only the instance and accepts state/accepted even if reply.version differs. That defeats the version field on every message and can let a new controller perform stop or release using incompatible monitor semantics instead of reporting a protocol mismatch; validate reply.version before consuming the result, as startup already does.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same fix as the pullfrog thread above, in 049fcff: request() now checks the reply's version along with its instance before using the result.

"[year][month][day]-[hour][minute][second]-[subsecond digits:3]"
))?;
fs::rename(&temporary, &parent.join(format!("{stem}-{stamp}.log")))?;
log.set_len(0)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset followers unambiguously after truncation

When a runtime refills the truncated file beyond a pv logs --follow reader's previous offset before linemux handles the filesystem event, linemux 0.3 sees a size that never decreased and resumes from the stale offset, silently dropping the beginning of the new log. This is reproducible with an 11-byte old file, where a longer post-rotation line is emitted only as its suffix; the added test cannot catch this because its replacement line is shorter than the previous offset, so rotation needs a mechanism that lets followers unambiguously reset.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this one. linemux 0.3 does detect truncation only when the size has shrunk below the reader's offset (reader.rs:496). But a monitor rotates only once a log passes 10 MiB, so a follower that is caught up sits at 10 MiB or more. To miss the truncation, the runtime would have to write over 10 MiB before linemux handles one file event, and the effect is only on pv logs --follow output, not on stored logs. If it ever matters, the fix belongs in the follower (for example, checking the inode or file size on each event), not in rotation.

Comment on lines +960 to +964
let written = write_line(&mut connection, &Reply::new(instance, result)).await;
if released {
send(commands, Command::Exit).await?;
}
written?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove records when an accepted release loses its client

If a release client disconnects or is canceled after submitting its request, write_line can fail, but this path still sends Command::Exit before propagating that failure. The monitor exits while release_monitor never reaches the reservation wait and directory removal, leaving monitor.json behind so the next start_monitor returns MonitorUnresolved until an explicit recovery; make accepted release cleanup self-contained or avoid committing the exit when the response was not delivered.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 049fcff: the monitor now removes its own records when it accepts a release. It still holds the reservation at that point, so no replacement can own those records; the path it takes is the one it already used to release itself when the lifeline closes. release_monitor now only waits for the reservation to prove the monitor exited, and its re-read-and-compare code is gone. A test now releases with a bare request and no controller-side cleanup, then checks the records are gone.

A monitor now removes its own records when it accepts a release, while
it still holds the reservation, so a controller that disconnects after
releasing no longer leaves records that block the next start.
release_monitor only waits for the reservation.

stop_monitor no longer times out on the caller's grace period. The
monitor's stop is bounded by the stop it actually runs, and each state
request has its own timeout.

Controllers reject replies from another protocol version, as startup
already did. The monitor backs off after an accept error instead of
spinning, as the daemon's servers do. Test pause points give up after
150 s, so a test that dies at one leaves no paused monitor behind.
@clvsh

clvsh commented Oct 10, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai Thanks. On your two review-body notes:

  • stop_monitor deadline: fixed in 049fcff by removing the controller-side deadline, which covers both the fallback stop and an earlier, longer stop. The monitor's own stop bounds the wait, and each state request has its own timeout.
  • Recovery race between the identity check and the group signal: agreed it's real. It already exists in today's supervisor, and nothing in production uses monitor recovery yet, so I'm carrying it to PR 4, where monitors replace the supervisor's adoption path.

I'm not acting on the docstring-coverage warning.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes Re-reviewed 049fcffc against the prior Pullfrog review at 259602b0, with the full PR diff as context.

  • Fixed reply validation so normal controller requests reject incompatible protocol versions as well as mismatched instances.
  • Bounded fixture pauses so early startup holds no longer prevent eventual cleanup after the test process dies.
  • Moved release cleanup into the monitor while its reservation is held, and updated the raw-release contract test to verify record removal without controller-side cleanup.
  • Removed the competing stop timeout so repeated calls wait for the monitor's original stop executor, and updated the repeated-stop test to use the controller API.
  • Added accept-error backoff to prevent a persistent listener failure from causing a busy loop.

Validation: cargo fmt --all --check and the portable monitor CLI capability test passed. The initial test build exceeded the tool's 120-second limit; retry passed. The changed macOS process-contract tests could not run on this Linux runner, and macOS CI was still in progress. Separate-controller death during early pauses and incompatible authenticated replies remain untested directly.

Pullfrog  | View workflow run | Using gpt-6.1-sol | 𝕏

@coderabbitai

coderabbitai Bot commented Oct 10, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@clvsh Thanks for the update and scope clarification.

  • stop_monitor deadline: Removing the controller-side deadline addresses the concern as described. The monitor bounds the stop operation, and each state request has its own timeout.
  • Recovery race: The concern remains valid but is deferred to PR 4. Production does not use monitor recovery in this PR. Would you like me to open a follow-up issue for the identity-check-to-group-signal race?
  • Docstring coverage: Understood. No change requested.

✏️ Learnings added
Learnt from: clvsh
URL: https://github.com/prvious/pv/pull/412

Timestamp: 2026-10-10T02:55:38.957Z
Learning: In prvious/pv PR #412, production does not start monitors or use monitor recovery. Monitor integration and replacement of the supervisor's legacy adoption path are planned for PR 4. The recovery race between checking runtime identity and signaling its process group also exists in the current supervisor; the author plans to address it in PR 4.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 049fcffc28

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

/// The monitor removes its own records before it exits; its reservation proves it has exited.
pub async fn release_monitor(paths: &PvPaths, subject: &str) -> Result<(), DaemonError> {
request(paths, subject, Operation::Release).await?;
drop(wait_for_reservation(paths, subject).await?);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Propagate monitor record-removal failures

If removing the monitor directory fails during Command::Exit—for example, because its permissions changed or the filesystem reports an I/O error—the monitor exits and releases its reservation, so this wait succeeds and release_monitor returns Ok(()) even though monitor.json remains and the next start fails with MonitorUnresolved. The disconnect case is now handled, but the current deletion result is still lost across process exit; verify record removal after acquiring the reservation or acknowledge the deletion result before reporting release success.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this as is. Getting here takes a failed delete of PV's own 0700 directory, which means a disk or permissions fault. Nothing is lost when it happens: the next start_monitor reports MonitorUnresolved, and recover_monitor finds no live group member for the recorded runtime, so it removes the leftover records without sending any signal. Having the controller remove records again after release would also need the instance comparison we just dropped, so that a release never deletes a replacement's evidence; that isn't worth it for this case.

Comment thread crates/daemon/src/monitor/macos.rs Outdated

fn read_record(paths: &PvPaths, subject: &str) -> Result<Option<MonitorRecord>, DaemonError> {
match fs::read_to_string(&record_path(paths, subject)) {
Ok(content) => Ok(Some(serde_json::from_str(&content)?)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject persisted records from another protocol version

When a dead monitor leaves a record from another application version whose JSON remains structurally deserializable, recover_monitor trusts its process identity and fallback stop policy without checking record.version, potentially signaling the recorded group and deleting recovery evidence under incompatible semantics. Validate the persisted version before any recovery action; this is distinct from validating the live monitor's reply version.

AGENTS.md reference: AGENTS.md:L5-L5

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 388e7a9. read_record is the one loader for start, requests and recovery, and it now refuses a record whose version isn't this protocol's with InvalidRuntimeRecord, before any signal or deletion. The recovery test now covers a foreign-version record that has the exact runtime identity from this boot: recovery refuses it, sends no signal and keeps the record.

Starting, requesting and recovering a monitor all read its record
through one loader, which now refuses a record written under another
protocol version instead of trusting its identities and stop policy.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes Re-reviewed 388e7a97 against the prior Pullfrog review at 049fcffc, with the complete PR diff as context.

  • Rejected incompatible monitor records in the shared reader before startup, controller requests, or recovery can act on their identities or stop policy.
  • Extended recovery regression coverage with an exact-identity, current-boot runtime recorded under protocol version 2, checking typed rejection, record preservation, and that the runtime remains alive.

Validation: cargo fmt --all --check passed. The changed implementation and regression test are macOS-only, so behavioral tests could not run on this Linux runner; all four macOS CI lanes were still in progress when checked.

Pullfrog  | View workflow run | Using gpt-6.1-sol | 𝕏

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 388e7a9782

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

/// The monitor removes its own records before it exits; its reservation proves it has exited.
pub async fn release_monitor(paths: &PvPaths, subject: &str) -> Result<(), DaemonError> {
request(paths, subject, Operation::Release).await?;
drop(wait_for_reservation(paths, subject).await?);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Distinguish a replacement monitor during release

When another controller starts the same subject immediately after the accepted release, the replacement can acquire the reservation before this poll does. wait_for_reservation cannot distinguish the replacement's lock from the released monitor's lock, so it waits five seconds and returns MonitorUnavailable even though the requested instance exited successfully; use the MonitorRecord returned by request to recognize that handoff and add concurrent process-level coverage for it.

AGENTS.md reference: AGENTS.md:L9-L12

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this. Today only tests call release_monitor. In PR 4 the only controller that starts and releases monitors is the daemon, and DESIGN.md has it run reconciliation jobs one at a time through jobs.lock, so a release and a start for the same subject never overlap. The other path that stops runtimes, daemon:disable and uninstall, stops the daemon first and holds the runtime lifecycle lock exclusively. If it ever did happen, the cost is a spurious MonitorUnavailable after 5 s with no state damage. A free reservation is also a stronger proof that the monitor exited than a pid and birth-identity check, which depends on how macOS reports exiting processes.

// A dot file, so `pv logs` never shows a partial archive.
let temporary = parent.join(format!(".{name}.rotating"));
fs::remove_file_if_exists(&temporary)?;
let mut archive = fs::create_new_file(&temporary)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve private permissions on rotated logs

When a runtime reaches the rotation threshold under a typical 022 umask, create_new_file creates this temporary archive as 0644, and neither this path nor the generic helper subsequently applies secure_sensitive_file; the rename preserves that mode. The archive contains the same potentially sensitive output as the active log, which open_private_log_file explicitly forces to owner-only 0600, so create the temporary archive through a sensitive-file helper or explicitly secure it and assert the archive mode in rotation coverage.

AGENTS.md reference: AGENTS.md:L9-L9

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this. The boundary here is the directory: DESIGN.md requires ~/.pv to be 0700, pv setup creates it that way and pv doctor checks it, so other users can't reach anything under ~/.pv/logs whatever each file's mode. DESIGN.md's list of 0600 files is pv.db, CA private keys and generated secrets, and logs aren't on it. Changing the archive mode wouldn't close any real exposure.

let stamp = OffsetDateTime::now_utc().format(format_description!(
"[year][month][day]-[hour][minute][second]-[subsecond digits:3]"
))?;
fs::rename(&temporary, &parent.join(format!("{stem}-{stamp}.log")))?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Sync the archive rename before truncating the source

Although copy_prefix syncs the temporary archive's contents, this rename changes the parent directory without syncing it before set_len(0) truncates the active log. If the machine or kernel crashes after rotation, the filesystem may persist the truncation while losing the unsynced archive directory entry, leaving neither durable copy of those log bytes; call sync_parent_directory after the rename and before truncation, as the existing atomic-copy helper already does.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this. PV never syncs log data: runtimes append to their active logs without fsync, and sync_parent_directory is used only for atomic config and state writes. A crash already loses the tail of any active log, so making the archive's directory entry durable would protect data that wasn't durable to begin with.

… cleanup

On macOS, killpg answers EPERM when no member of a group can take a
signal: each is an unreaped zombie or already exiting. A killed
runtime's children are briefly in that state, often for far longer
when they hold a lot of memory. signal_process_group accepted EPERM
only once the group had already exited, so a monitor's fallback stop
that started while a child was still tearing down failed, and the
monitor marked its cleanup unproven, which blocks the runtime until
a reboot.

EPERM now leaves the decision to the group-exit wait that every caller
already runs. A pv-fake descendant can now hold resident memory, which
makes the test reproduce this every time.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
crates/daemon/tests/monitor.rs (1)

267-270: 🩺 Stability & Availability | 🔵 Trivial | 🏗️ Heavy lift

Make the EPERM regression test exercise EPERM.

The test can reach monitor cleanup after the direct group kill, but DescendantResident only proves that the descendant allocated memory. It does not prove that signal_process_group receives PermissionDenied while the descendant is still exiting. The cleanup assertions can therefore pass without distinguishing the changed branch. Add a targeted signal seam or assertion that confirms EPERM occurred before asserting cleanup.


ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: QUIET
  • Plan: Advanced
  • Run ID: 877d2328-c028-4da5-8d60-52c0da57b454
📥 Commits

Reviewing files that changed from the base of the PR and between 388e7a9 and f9a9f59.

📒 Files selected for processing (6)
  • crates/daemon/src/supervisor.rs
  • crates/daemon/tests/monitor.rs
  • crates/pv-fake/src/events.rs
  • crates/pv-fake/src/fake.rs
  • crates/pv-fake/src/lib.rs
  • crates/pv-fake/tests/contracts.rs

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 8 remain after this review.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes Re-reviewed f9a9f593 against the prior Pullfrog review at 388e7a97, with the complete PR diff as context.

  • Fixed exiting-group cleanup by allowing EPERM to proceed to the existing group-exit wait instead of immediately marking cleanup unproven; successful cleanup still requires verified absence of live members.
  • Added resident-memory fixture support so a fake descendant can retain memory and report when it is ready for the teardown scenario.
  • Extended monitor regression coverage by killing a runtime group with a memory-resident descendant and requiring signal-9 exit, complete cleanup, and successful release.

Validation: cargo fmt --all --check and the portable descendant lifecycle test passed. Used cargo test because nextest is unavailable. The new macOS teardown regression could not run on this Linux runner; Linux and Windows portability checks passed, while all four macOS CI lanes remained in progress.

Pullfrog  | View workflow run | Using gpt-6.1-sol | 𝕏

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f9a9f593c2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +255 to +257
match platform::inspect_process_start_identity(runtime.pid)? {
Some(identity) if identity == runtime.start_identity => {
stop_process_group(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Verify the recorded executable before recovery signals

When a current-boot runtime retains its PID and start identity but re-execs to a different executable, this branch still treats it as owned and signals its entire process group. That is weaker than the persisted-runtime ownership invariant in CONTRIBUTING.md, and it leaves the recorded command and arguments unused during recovery. Validate the full live process identity against the record before the initial signal and again before escalation, otherwise recovery can terminate a group that no longer matches the recorded runtime.

AGENTS.md reference: AGENTS.md:L3-L4

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipping this one. The pid plus its start time already rule out pid reuse, and that's what this check guards against. A runtime that re-execs keeps that identity because it's still the process PV started, in PV's own process group, so stopping its group is still correct. Checking the recorded command would also be wrong here: the recorded runtime begins as the pv monitor:gate process and only becomes the command after exec. If a monitor dies before that exec, a command check would refuse to stop the gate and leak it.

let rotation_period = hooks
.rotation_interval_ms
.map_or(ROTATION_INTERVAL, Duration::from_millis);
let mut rotation_tick = interval_at(Instant::now() + rotation_period, rotation_period);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject a zero rotation interval before startup

When test-hook JSON supplies rotation_interval_ms: 0, this passes Duration::ZERO to Tokio's interval_at, which asserts that the period is nonzero. The runtime has already started and the successful startup reply was sent before this code runs, so the monitor process panics and leaves the runtime running with recovery records instead of supervising its lifeline; reject zero during hook loading/startup or represent the interval as a nonzero type.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipping this one. Only test hook files can set the rotation interval. Production pv monitor:run always uses MonitorHooks::default() (crates/cli/src/commands/daemon.rs), and only the pv-monitor test example reads pv-monitor-hooks.json. The one test that sets it uses 20 ms.

Comment on lines +430 to +431
// A held rotation or the lifeline reader must not keep the monitor alive.
runtime.shutdown_background();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove partial rotation files before monitor shutdown

When release or lifeline cleanup completes while the blocking rotation task is copying, the monitor returns without joining that task and this shutdown lets the process exit immediately. rotate_if_needed has already created .NAME.rotating, while its removal path only runs when copy_prefix returns an error normally, so process termination leaves a partial archive behind; if the runtime is not restarted, that hidden file persists indefinitely and can duplicate much of an arbitrarily large active log. Unlink the temporary archive as part of intentional monitor shutdown or otherwise make cancellation clean it up without waiting for the copy.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipping this one. The next rotation of that log deletes any leftover .NAME.rotating file before creating a new one (rotation.rs:49). So it's at most one hidden file per log, about the 10 MiB rotation threshold. It also needs a release or a closed lifeline to land inside the few milliseconds the copy takes. I don't think that's worth a cancellation path.

@clvsh

clvsh commented Oct 11, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai on the nitpick that monitor_cleans_up_a_killed_group_whose_member_is_still_exiting doesn't prove EPERM happened: I checked it against the old check. With the previous signal_process_group, which accepted EPERM only once the group had already exited, the test failed 5 out of 5 runs: the monitor reported cleanup unproven and refused release. With f9a9f59 it passed 20 out of 20. A probe also showed killpg returning EPERM for over 10,000 polls while a 1.5 GiB process tore down. So the 256 MiB descendant reliably exercises that path without adding a signal seam to production code.

@coderabbitai

coderabbitai Bot commented Oct 11, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@clvsh Thanks for the evidence. The old-code failures in 5/5 runs and the passes with f9a9f593 in 20/20 runs establish useful regression coverage. The separate killpg probe supports the teardown explanation.

I withdraw the nitpick. No production signal seam is needed for this test.


✏️ Learnings added
Learnt from: clvsh
URL: https://github.com/prvious/pv/pull/412

Timestamp: 2026-10-11T01:05:38.767Z
Learning: In prvious/pv, crates/daemon/tests/monitor.rs contains monitor_cleans_up_a_killed_group_whose_member_is_still_exiting, which uses a 256 MiB descendant to exercise macOS process-group teardown. The author reports that the test failed 5/5 runs with the previous signal_process_group behavior, which accepted EPERM only after group exit, and passed 20/20 runs with f9a9f593. A separate probe observed killpg returning EPERM for over 10,000 polls during teardown of a 1.5 GiB process. This regression comparison supports the process-level test without adding a signal seam to production code.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

@clvsh
clvsh merged commit 8fe7d31 into main Oct 11, 2026
10 checks passed
@clvsh
clvsh deleted the feat/service-monitor-core branch October 11, 2026 03:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant