Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/skills/release-app-version/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Use this workflow for PV application releases. This is separate from Managed Res
- `PV App Publication`: git ref, `source_run_id`
- whether this is build-only or build-and-publish

Do not silently rely on defaults. Current checked-in defaults are `minimum_pv_version=0.1.0` and `app_platforms=darwin-arm64`, but always re-read the workflow first.
Do not silently rely on defaults. Current checked-in defaults are `minimum_pv_version=0.2.6` and `app_platforms=darwin-arm64`, but always re-read the workflow first. Never set `minimum_pv_version` below 0.2.6 for a release after 0.2.6: only 0.2.6 moves runtimes from pid files onto monitors, so a lower minimum lets `pv update` skip that step. The workflow rejects lower values.

## Bump The App Version

Expand Down
10 changes: 8 additions & 2 deletions .github/workflows/app-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ on:
workflow_dispatch:
inputs:
minimum_pv_version:
description: "Minimum installed PV version that may apply this app release"
description: "Minimum installed PV version that may apply this app release; at least 0.2.6, which moves pid-file runtimes onto monitors"
required: true
default: "0.1.0"
default: "0.2.6"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reject release minimums below 0.2.6

Because workflow_dispatch inputs are editable, a post-0.2.6 release can still be dispatched with minimum_pv_version=0.2.5: the prepare step only rejects an empty value and publishes every other string unchanged. This lets a pre-0.2.6 installation skip the sole pid-file-to-monitor migration after this commit removes the legacy-runtime stop path, leaving existing runtimes unmanaged or causing reconciliation to collide with their occupied ports. Enforce a parsed minimum of 0.2.6 in the workflow rather than relying on the default.

AGENTS.md reference: AGENTS.md:L4-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.

Agreed. The default alone left the input editable. Fixed in 9b5dc52: the release metadata step now fails unless minimum_pv_version sorts at or above 0.2.6, checked with sort -V on the macOS runner. 0.2.5 and 0.1.0 are rejected; 0.2.6, 0.2.10 and 0.3.0 pass. DESIGN.md and the release skill now say the workflow enforces it.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
type: string
app_platforms:
description: "Comma-separated app platforms to release; default is the Apple Silicon preview lane"
Expand Down Expand Up @@ -60,6 +60,12 @@ jobs:
printf '%s\n' "minimum_pv_version is required" >&2
exit 1
fi
# Only 0.2.6 moves runtimes from pid files onto monitors; no later release may skip it.
lowest_minimum=$(printf '%s\n' 0.2.6 "$MINIMUM_PV_VERSION" | sort -V | head -n 1)
if [ "$lowest_minimum" != "0.2.6" ]; then
printf '%s\n' "minimum_pv_version must be at least 0.2.6, the release that moves runtimes onto monitors" >&2
exit 1
fi
case "$HELPER_ONLY" in
true|false) ;;
*)
Expand Down
52 changes: 28 additions & 24 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Avoid `panic!`, `unreachable!`, `.unwrap()`, and `.expect()` in production code.

For running tests, we recommend [nextest](https://nexte.st/).

Some tests require `python3` on `PATH`: the supervisor's script-identity tests, two supervisor process-group tests, and `pv-release`'s installer and smoke tests. They use only Python's standard library; no Python packages or virtual environment is required.
Some tests require `python3` on `PATH`: two supervisor process-group tests, and `pv-release`'s installer and smoke tests. They use only Python's standard library; no Python packages or virtual environment is required.

To run a specific test by name:

Expand All @@ -42,21 +42,26 @@ cargo insta test --accept --test-runner nextest -- <test_name>

## Fixture Lifecycle

Tests that start long-running Managed Resource or Gateway fixtures must register
each intended process's exact PID-path/runtime-metadata-path pair
before startup or reconciliation. Capture assertions while the runtime is live,
then use explicit cleanup where the guard exposes it so cleanup failures fail
the test. If an operation has already failed, preserve that primary failure
while also reporting cleanup failures. Cleanup validates the recorded metadata
and process identity, stops the complete owned process group, waits for its
members and listeners to disappear, and removes the exact records only after
verified stop.

Fixture guards must also clean up from `Drop` so normal return, early return,
panic unwind, and cancellation cannot bypass teardown. The fallback uses an
independent cleanup runtime and reports failures because `Drop` cannot return
them to the test. The Rust guard or supervisor owns whole-process-group
cleanup.
Tests that start long-running Managed Resource or Gateway runtimes run them
under the test monitor. Fixture setup calls `pv_fake::install_monitor(paths)`.
That links the daemon's `pv-monitor` example into the test's PV home as its
`pv` binary and gives the monitors the test process's lifeline. Capture
assertions while the runtime is live, then use explicit cleanup where the guard
exposes it so cleanup failures fail the test. If an operation has already
failed, preserve that primary failure while also reporting cleanup failures.
Cleanup goes through `stop_recorded_runtimes` or
`ProcessSupervisor::stop_subject`. The runtime's monitor stops its whole
process group and waits for the members to exit. PV removes the records only
after that verified stop.

Fixture guards also clean up from `Drop` where they can, so normal return,
early return, panic unwind, and cancellation cannot bypass teardown. The
fallback uses an independent cleanup runtime and reports failures, because
`Drop` cannot return them to the test. Whenever the test process exits, even by
SIGKILL, its lifeline closes and every monitor it started stops its runtime's
process group and exits. If a monitor can't prove the group is gone, it keeps its
records for recovery, so check for leftover processes after an unexplained
failure.

Long-running fixtures that stand in for a runtime use `pv-fake`, not a script.
`pv_fake::install(executable, persona)` places a native fake at the
Expand All @@ -65,15 +70,13 @@ it dies, SIGKILL included, so it needs no parent watcher. It records its
lifecycle in `<executable>.pv-fake.events.jsonl`. `cargo nextest run` builds it
as the daemon's `pv-fake` example, but runs narrowed with `--lib` or
`--test <name>` do not; filter with `-E` instead. A fake from an older build
refuses to start and says how to rebuild. Guard registration and explicit
cleanup still apply. To stop a fake at a known point, pass `FakeSettings` to
refuses to start and says how to rebuild. Explicit cleanup still applies. To stop a fake at a known point, pass `FakeSettings` to
`pv_fake::install_with_settings` and wait on its events, such as `held` and
`descendant_spawned`, instead of marker files. Steer a running Gateway fake
with `pv_fake::write_gateway_control`, which rejects unknown keys.

Shell scripts remain only where a test needs what the fake doesn't do: the
supervisor's script-identity tests, which need a script as the runtime, and
the process-group tests, which need a child that outlives its parent or ignores
process-group tests, which need a child that outlives its parent or ignores
SIGTERM. A script that stays alive must give up on its own, so a test that dies
before stopping it leaves nothing running for long, but not before the CI
profile's 120-second test limit, so a stalled test can't pass because its
Expand Down Expand Up @@ -102,10 +105,11 @@ cargo nextest store export-chrome-trace nextest-run.zip -o trace.json
`NEXTEST_EXPERIMENTAL_RECORD=1 cargo nextest replay -R nextest-run.zip` replays
the run in the terminal.

Persisted-runtime cleanup must never discover ownership through process-name
scans or signal a PID found only in a record. A persisted PID is actionable
only together with matching recorded metadata and a verified process identity.
A test may signal and reap a child it directly spawned and still owns. These
Runtime cleanup must never discover ownership through process-name scans or
signal a PID found only in a record. A runtime is stopped through its monitor.
If the monitor has died, recovery signals the runtime's process group only
while the runtime identity and boot session in the monitor's record still
match. A test may signal and reap a child it directly spawned and still owns. These
guarantees do not cover machine or kernel failure, a test process that is
stopped rather than killed (its fakes keep running), or descendants that
deliberately escape their owned process group.
Expand Down
4 changes: 1 addition & 3 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,8 +266,6 @@ Before reconciliation starts a runtime, PV stops and releases any monitor alread

`pv daemon:disable` and `pv uninstall` stop and release every monitor, then remove records that no monitor backs. Only a successful Postgres exit status proves that its escaped backends are gone. Postgres exits 0 only after its backends exit, so a held exit 0 counts whoever stopped it. If Postgres ended by a signal, including a force-kill, or with another exit code, or its monitor is unavailable during the current boot, PV keeps its monitor and records; `--force` does not bypass this proof. A monitor record from an earlier boot is removed without signalling anything it names. A monitor record PV cannot read, such as one from another monitor protocol version, makes the command fail and keeps the records.

Runtimes that a PV release from before monitors started have only a pid file and a JSON record with the process start identity and boot session. Every reconciliation or update job first stops each one after verifying that identity, removes its pid file, and keeps its JSON record so reconciliation can use its config proofs and restart it under a monitor. The startup job need not run first: `pv update` queues its own job as soon as the new daemon is up. A job logs a runtime it could not stop and continues. A record from an earlier kernel boot is removed without signalling its PID, which may have been reused. `pv daemon:disable` and `pv uninstall` stop these runtimes the same way and remove both files. A live recorded PID that fails verification makes the command fail and keeps the records.

After daemon restart, PV adopts running runtimes through their monitors when their spec and config match desired state, so their PIDs stay the same. Reconciliation restarts runtimes that are stale, mismatched, exited, or no longer desired.

PV starts each supervised runtime in its own process group so it can stop the entire PV-owned process tree safely. The monitor runs each stop: the graceful signal, the grace period, then a force-kill of whatever remains in the group.
Expand Down Expand Up @@ -736,7 +734,7 @@ The daemon writes a minimal startup failure marker at `~/.pv/run/daemon-startup-

`pv update` does not create an extra `pv.db` backup before every PV application self-update. The embedded migration system creates a timestamped backup only when migrations are about to run.

PV application self-update restarts the daemon/control plane but does not stop currently running Gateway, Project-serving workers, or backing Managed Resource processes before swapping the PV binary. The updated daemon automatically schedules startup System reconciliation to apply current desired state, adopting running runtimes through their monitors. A monitor never re-executes its binary after startup, so pruning an older release does not affect it.
PV application self-update restarts the daemon/control plane but does not stop currently running Gateway, Project-serving workers, or backing Managed Resource processes before swapping the PV binary. The updated daemon automatically schedules startup System reconciliation to apply current desired state, adopting running runtimes through their monitors. A monitor never re-executes its binary after startup, so pruning an older release does not affect it. PV 0.2.6 is the only release that moves runtimes PV started before monitors, which it tracked by pid files, onto monitors. Later releases set `minimum_pv_version` to at least 0.2.6, so `pv update` cannot skip that step. The release workflow defaults to 0.2.6 and rejects any lower value.

App rollback is attempted after activation was attempted. Helper rollback is also attempted when helper replacement itself fails, before any incompatible app can be activated. If daemon startup, daemon health, or migration health fails after activation and rollback succeeds, stdout reports `PV application: update failed; rolled back to <previous-version>` and stderr reports the original failure. A generic daemon health failure is reported as `daemon did not become healthy after update`; migration failure is reported as `database migration failed after update: <message>`.

Expand Down
57 changes: 0 additions & 57 deletions crates/cli/tests/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -367,21 +367,18 @@ fn daemon_disable_stops_live_runtimes_without_a_plist_or_database() -> anyhow::R
worker.command = paths
.resources()
.join("frankenphp/8.5/fixture/bin/frankenphp");
worker.pid_path = paths.worker_pid("8.5-test");
worker.metadata_path = paths.worker_runtime_metadata("8.5-test");
let mut resource = gateway.clone();
resource.name = "mailpit".to_owned();
resource.resource_name = "mailpit".to_owned();
resource.track = "1".to_owned();
resource.command = paths.resources().join("mailpit/1/fixture/bin/mailpit");
resource.pid_path = paths.resource_pid("mailpit", "1");
resource.metadata_path = paths.resource_runtime_metadata("mailpit", "1");
let mut mysql = resource.clone();
mysql.name = "mysql".to_owned();
mysql.resource_name = "mysql".to_owned();
mysql.track = "8.4".to_owned();
mysql.command = paths.resources().join("mysql/8.4/fixture/bin/mysqld");
mysql.pid_path = paths.resource_pid("mysql", "8.4");
mysql.metadata_path = paths.resource_runtime_metadata("mysql", "8.4");
let mut fixtures = Vec::new();
for spec in [gateway, worker, resource, mysql] {
Expand Down Expand Up @@ -441,7 +438,6 @@ fn daemon_disable_waits_for_process_death_after_its_socket_is_gone() -> anyhow::
let mut daemon_spec = gateway_spec(&paths);
daemon_spec.command = paths.bin().join("releases/fixture/pv");
daemon_spec.arguments = vec!["daemon:run".to_owned()];
daemon_spec.pid_path = paths.run().join("fixture-daemon.pid");
daemon_spec.metadata_path = paths.run().join("fixture-daemon.json");
let mut daemon = RuntimeFixture::start(&paths, daemon_spec)?;
let pid = daemon.pid()?;
Expand Down Expand Up @@ -493,7 +489,6 @@ fn daemon_disable_keeps_dead_postgres_records_when_cleanup_is_unproven() -> anyh
let mut spec = gateway_spec(&paths);
spec.resource_name = "postgres".to_owned();
spec.track = "18".to_owned();
spec.pid_path = paths.resource_pid("postgres", "18");
spec.metadata_path = paths.resource_runtime_metadata("postgres", "18");
let mut runtime = RuntimeFixture::start(&paths, spec)?;
// Killed, Postgres exits by signal, which proves nothing about its backends.
Expand All @@ -511,55 +506,6 @@ fn daemon_disable_keeps_dead_postgres_records_when_cleanup_is_unproven() -> anyh
Ok(())
}

#[test]
fn daemon_disable_removes_previous_boot_postgres_records_without_signalling_reused_pid()
-> anyhow::Result<()> {
let tempdir = tempdir()?;
let home = tempdir.path().join("home");
let paths = PvPaths::for_home(&home);
let environment = TestEnvironment::new(
&home,
tempdir.path(),
&paths.active_pv_binary(),
&tempdir.path().join("absent.plist"),
);
// A process outside PV now holds the pid that a previous boot's Postgres recorded.
let foreign = ChildGuard(
TestCommand::new("/bin/sleep")
.arg("30")
.process_group(0)
.spawn()?,
);
let foreign_pid = foreign.0.id();
let previous_boot =
platform::BootSessionId::try_from("00000000-0000-0000-0000-000000000001".to_owned())?;
assert_ne!(previous_boot, platform::current_boot_session_id()?);
let pid_path = paths.resource_pid("postgres", "18");
let metadata_path = paths.resource_runtime_metadata("postgres", "18");
state::fs::write_sensitive_file(&pid_path, &format!("{foreign_pid}\n"))?;
let metadata = json!({
"name": "postgres",
"pid": foreign_pid,
"command": "/bin/sleep",
"arguments": ["30"],
"resource_name": "postgres",
"track": "18",
"log_path": paths.logs().join("postgres.log"),
"started_at": "2026-10-09T00:00:00Z",
"boot_session_id": previous_boot,
"process_start_identity": platform::inspect_process_start_identity(foreign_pid)?,
});
state::fs::write_sensitive_file(&metadata_path, &serde_json::to_string(&metadata)?)?;

let output = run_pv(&["daemon:disable"], &environment)?;
assert_eq!(output.exit_code, ExitCode::SUCCESS);
assert!(!state::fs::path_entry_exists(&pid_path)?);
assert!(!state::fs::path_entry_exists(&metadata_path)?);
assert!(platform::process_group_has_live_members(foreign_pid)?);
drop(foreign);
Ok(())
}

#[test]
fn daemon_disable_clears_a_previous_boot_postgres_monitor_without_signalling_reused_pid()
-> anyhow::Result<()> {
Expand All @@ -575,7 +521,6 @@ fn daemon_disable_clears_a_previous_boot_postgres_monitor_without_signalling_reu
let mut spec = gateway_spec(&paths);
spec.resource_name = "postgres".to_owned();
spec.track = "18".to_owned();
spec.pid_path = paths.resource_pid("postgres", "18");
spec.metadata_path = paths.resource_runtime_metadata("postgres", "18");
let mut runtime = RuntimeFixture::start(&paths, spec)?;
runtime.cleanup()?;
Expand Down Expand Up @@ -639,7 +584,6 @@ fn daemon_replacement_waits_for_delayed_exit_before_bootstrap() -> anyhow::Resul
let mut spec = gateway_spec(&paths);
spec.command = paths.bin().join("releases/fixture/pv");
spec.arguments = vec!["daemon:run".to_owned()];
spec.pid_path = paths.run().join("fixture-daemon.pid");
spec.metadata_path = paths.run().join("fixture-daemon.json");
let mut previous = RuntimeFixture::start(&paths, spec)?;
let pid = previous.pid()?;
Expand Down Expand Up @@ -714,7 +658,6 @@ fn daemon_replacement_refuses_to_bootstrap_while_previous_daemon_is_alive() -> a
let mut spec = gateway_spec(&paths);
spec.command = paths.bin().join("releases/fixture/pv");
spec.arguments = vec!["daemon:run".to_owned()];
spec.pid_path = paths.run().join("fixture-daemon.pid");
spec.metadata_path = paths.run().join("fixture-daemon.json");
let mut daemon = RuntimeFixture::start(&paths, spec)?;
let pid = daemon.pid()?;
Expand Down
3 changes: 1 addition & 2 deletions crates/cli/tests/support/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ impl RuntimeFixture {
fixture.process = Some(runtime.block_on(supervisor.start(fixture.spec.clone()))?);
fixture.ownership = Some(
supervisor
.adopt_recorded(&fixture.spec.pid_path, &fixture.spec.metadata_path)?
.adopt_recorded(&fixture.spec.metadata_path)?
.ok_or_else(|| anyhow!("fixture did not publish ownership"))?,
);
for _attempt in 0..200 {
Expand Down Expand Up @@ -140,7 +140,6 @@ pub fn gateway_spec(paths: &PvPaths) -> ProcessSpec {
config_path: paths.gateway_root_config(),
config_fingerprint: None,
log_path: paths.gateway_supervisor_log(),
pid_path: paths.gateway_pid(),
metadata_path: paths.gateway_runtime_metadata(),
resource_name: "caddy".to_owned(),
track: "2".to_owned(),
Expand Down
Loading
Loading