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
28 changes: 26 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,39 @@ permissions:

jobs:
rust:
name: Rust
runs-on: macos-14
name: Rust (${{ matrix.name }})
strategy:
fail-fast: false
matrix:
include:
- name: macOS 14 arm64
runner: macos-14
macos_major: "14"
architecture: arm64
- name: macOS 15 Intel
runner: macos-15-intel
macos_major: "15"
architecture: x86_64
- name: macOS 26 arm64
runner: macos-26
macos_major: "26"
architecture: arm64
Comment on lines +26 to +29

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 Stabilize the macOS 26 lane before requiring it

When the newly added macOS 26 lane runs cargo nextest run --workspace --all-features --locked, this commit's own validation reports three Mailpit HTTP-readiness timeouts on that host. The fixture changes in this diff only add stderr diagnostics and error context—and still use ThreadingHTTPServer—so they do not resolve the reported failures; enabling this required lane therefore leaves PR and push CI red whenever those timeouts reproduce. Stabilize the affected tests before requiring this lane, or temporarily isolate the failing coverage.

Useful? React with 👍 / 👎.

runs-on: ${{ matrix.runner }}

steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false

- name: Verify supported macOS host
env:
EXPECTED_ARCHITECTURE: ${{ matrix.architecture }}
EXPECTED_MACOS_MAJOR: ${{ matrix.macos_major }}
run: |
test "$(sw_vers -productVersion | cut -d. -f1)" = "$EXPECTED_MACOS_MAJOR"
test "$(uname -m)" = "$EXPECTED_ARCHITECTURE"

- name: Install Rust
uses: dtolnay/rust-toolchain@stable

Expand Down
12 changes: 0 additions & 12 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ indicatif = { version = "0.18.6", default-features = false, features = ["unicode
insta = { version = "1", features = ["filters"] }
libc = "0.2"
linemux = "0.3.0"
netstat = { package = "netstat-esr", version = "0.8.1" }
object_store = { version = "0.13.2", default-features = false, features = ["aws"] }
rcgen = { version = "0.14.8", features = ["x509-parser"] }
redis = { version = "1.2.2", default-features = false, features = ["tokio-comp"] }
Expand Down
8 changes: 6 additions & 2 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ Per-project versions are supported too — add a pv.yml file with php: "8.4" in

## Platform Scope

PV v1 targets macOS 13 and newer. Stabilizing the macOS application remains the immediate product priority.
PV v1 supports macOS 14 and newer. Stabilizing the macOS application remains the immediate product priority.

macOS 13 may continue to run PV when the application and Managed Resource binaries remain compatible, but it is untested and unsupported. Dropping support does not by itself require raising binary deployment targets or republishing otherwise compatible Managed Resource artifacts. Before PV deliberately ships an application binary that cannot run on macOS 13, the application update manifest and updater must prevent an incompatible update from being activated there.

The full macOS CI quality and behavior suite covers every supported macOS major version and both supported architectures across a representative matrix rather than every version/architecture combination. The initial matrix is macOS 14 on Apple Silicon, macOS 15 on Intel, and macOS 26 on Apple Silicon. Private-interface acceptance tests such as listener inspection run as part of every matrix lane. New supported macOS major versions must be added to the matrix before PV relies on behavior there.

Linux and Windows are committed subsequent platforms. During macOS stabilization, the installed application and runtime crates compile natively on macOS, Linux, and Windows so new system boundaries do not create unnecessary portability blockers.

Expand Down Expand Up @@ -893,7 +897,7 @@ PV v1 relies on HTTPS trust for the artifact manifest itself plus SHA-256 verifi

Public v1 should support separate Managed Resource artifacts for both Apple Silicon and Intel macOS: `darwin-arm64` and `darwin-amd64`. PV v1 does not use universal macOS Managed Resource artifacts. If build complexity blocks progress, Apple Silicon-only is acceptable for an initial preview, but not as the intended public v1 scope.

Managed Resource artifact recipes set an explicit macOS deployment target of macOS 13.0 unless a later design decision raises PV's minimum supported macOS version. Recipes must not silently inherit a newer GitHub runner deployment target.
Managed Resource artifact recipes retain an explicit macOS deployment target of macOS 13.0 even though PV supports macOS 14 and newer. Keeping otherwise compatible artifacts runnable on macOS 13 does not make that operating system supported. Recipes must not silently inherit a newer GitHub runner deployment target, and raising an artifact deployment target requires a separate compatibility decision.

The artifact manifest may use `platform: "any"` only for truly portable artifacts that do not contain platform-specific binaries. Composer is the expected v1 `platform: "any"` artifact because PV packages `composer.phar` inside a PV-owned archive. Native Managed Resource artifacts use explicit platform values such as `darwin-arm64` or `darwin-amd64`.

Expand Down
2 changes: 1 addition & 1 deletion crates/cli/tests/php.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ impl TestEnvironment {
home: home.as_std_path().to_path_buf(),
current_dir: RefCell::new(current_dir.as_std_path().to_path_buf()),
client,
target_platform: None,
target_platform: Some(TargetPlatform::DarwinArm64),
target_platform_resolution_fails: false,
exec_calls: RefCell::new(Vec::new()),
}
Expand Down
1 change: 1 addition & 0 deletions crates/cli/tests/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1621,6 +1621,7 @@ mod update_tests {
while let Some(response) = responses.front() {
match listener.accept() {
Ok((mut stream, _address)) => {
stream.set_nonblocking(false)?;
let mut request = String::new();
BufReader::new(stream.try_clone()?).read_line(&mut request)?;
stream.write_all(format!("{response}\n").as_bytes())?;
Expand Down
49 changes: 42 additions & 7 deletions crates/daemon/src/managed_resources/tests.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use std::collections::BTreeMap;
use std::fmt::Debug;
use std::net::TcpListener;
use std::os::unix::fs::PermissionsExt;
use std::sync::{Arc, Mutex};
Expand All @@ -9,7 +10,7 @@ use crate::{
managed_resources::{ManagedResourceRuntimeAdapter, ManagedResourceRuntimeContext},
reconciliation::{ReconciliationQueue, ReconciliationScope},
};
use anyhow::{Result, bail};
use anyhow::{Result, anyhow, bail};
use camino::Utf8Path;
use camino_tempfile::tempdir;
use insta::{Settings, assert_debug_snapshot};
Expand Down Expand Up @@ -664,7 +665,10 @@ async fn mailpit_reconciliation_records_smtp_and_dashboard_env() -> Result<()> {
let mailpit_port_guards = seed_mailpit_runtime_ports(&paths, FAKE_MAILPIT_TRACK)?;

drop(mailpit_port_guards);
crate::project_env::reconcile_project_env(&paths, &project.id).await?;
mailpit_fixture_result(
&paths,
crate::project_env::reconcile_project_env(&paths, &project.id).await,
)?;
let snapshot = {
let database = Database::open(&paths)?;
let runtime_states = database.runtime_observed_states()?;
Expand Down Expand Up @@ -720,12 +724,15 @@ async fn mailpit_project_demand_installs_missing_fixture_track_before_start() ->
let mailpit_port_guards = seed_mailpit_runtime_ports(&paths, FAKE_MAILPIT_TRACK)?;

drop(mailpit_port_guards);
reconcile_project_env_with_mailpit_runtime_catalog_and_manifest_url(
mailpit_fixture_result(
&paths,
&project.id,
OFFLINE_TEST_MANIFEST_URL,
)
.await?;
reconcile_project_env_with_mailpit_runtime_catalog_and_manifest_url(
&paths,
&project.id,
OFFLINE_TEST_MANIFEST_URL,
)
.await,
)?;
let snapshot = {
let database = Database::open(&paths)?;
let runtime_states = database.runtime_observed_states()?;
Expand Down Expand Up @@ -1971,6 +1978,14 @@ async fn demanded_resource_cleans_runtime_files_when_process_exits_after_readine
drop(mailpit_port_guards);
let result =
reconcile_project_env_with_fast_exit_fake_runtime_catalog(&paths, &project.id).await;
if let Err(error) = &result
&& !matches!(
error.downcast_ref::<DaemonError>(),
Some(DaemonError::UnexpectedProtocolResponse { .. })
)
{
return Err(mailpit_fixture_error(&paths, error));
}
let failure_snapshot = {
let database = Database::open(&paths)?;
let runtime_states = database.runtime_observed_states()?;
Expand Down Expand Up @@ -4862,6 +4877,26 @@ fn runtime_files_exist(paths: &PvPaths, track: &str) -> Result<RuntimeFilePresen
runtime_files_exist_for_resource(paths, "mailpit", track)
}

fn mailpit_fixture_result<T, E>(paths: &PvPaths, result: Result<T, E>) -> Result<T>
where
E: Debug,
{
match result {
Ok(value) => Ok(value),
Err(error) => Err(mailpit_fixture_error(paths, &error)),
}
}

fn mailpit_fixture_error(paths: &PvPaths, error: &impl Debug) -> anyhow::Error {
let log_path = paths.resource_log("mailpit", FAKE_MAILPIT_TRACK);
match state::fs::read_to_string(&log_path) {
Ok(log) => anyhow!("Mailpit fixture failed with {error:#?}\n{log}"),
Err(log_error) => anyhow!(
"Mailpit fixture failed with {error:#?}; failed to read {log_path}: {log_error}"
),
}
}

fn runtime_files_exist_for_resource(
paths: &PvPaths,
resource_name: &str,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
#!/usr/bin/env python3
import http.server
import signal
import socketserver
import sys


class Server(http.server.ThreadingHTTPServer):
def server_bind(self):
# Avoid HTTPServer's unnecessary FQDN lookup for a loopback fixture.
socketserver.TCPServer.server_bind(self)
self.server_name, self.server_port = self.server_address[:2]


signal.signal(signal.SIGUSR1, signal.SIG_IGN)
port = int(sys.argv[1])
with http.server.ThreadingHTTPServer(
("127.0.0.1", port), http.server.SimpleHTTPRequestHandler
) as server:
with Server(("127.0.0.1", port), http.server.SimpleHTTPRequestHandler) as server:
server.serve_forever()
14 changes: 10 additions & 4 deletions crates/daemon/test-fixtures/gateway/fake-frankenphp-server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import http.server
import re
import signal
import socketserver
import ssl
import sys
import threading
Expand Down Expand Up @@ -32,18 +33,23 @@ def log_message(self, format, *args):
pass


class Server(http.server.ThreadingHTTPServer):
def server_bind(self):
# Avoid HTTPServer's unnecessary FQDN lookup for a loopback fixture.
socketserver.TCPServer.server_bind(self)
self.server_name, self.server_port = self.server_address[:2]


http_port = int(required(r"^# PV_FAKE_PORT (\d+)$"))
https_port = optional(r"^\s*https_port (\d+)$")
cert_path = optional(r'^\s*cert "([^"]+)"$')
key_path = optional(r'^\s*key "([^"]+)"$')
servers = [http.server.ThreadingHTTPServer(("127.0.0.1", http_port), Handler)]
servers = [Server(("127.0.0.1", http_port), Handler)]

if https_port is not None and cert_path is not None and key_path is not None:
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain(certfile=cert_path, keyfile=key_path)
https_server = http.server.ThreadingHTTPServer(
("127.0.0.1", int(https_port)), Handler
)
https_server = Server(("127.0.0.1", int(https_port)), Handler)
https_server.socket = context.wrap_socket(https_server.socket, server_side=True)
servers.append(https_server)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,20 @@ def handle(self):
self.request.sendall(b"220 fake mailpit\r\n")


class HttpServer(http.server.ThreadingHTTPServer):
def server_bind(self):
# Avoid HTTPServer's unnecessary FQDN lookup for a loopback fixture.
socketserver.TCPServer.server_bind(self)
self.server_name, self.server_port = self.server_address[:2]


class TcpServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
allow_reuse_address = True
daemon_threads = True


smtp = TcpServer(("127.0.0.1", int(smtp_port)), SmtpHandler)
dashboard = http.server.ThreadingHTTPServer(
dashboard = HttpServer(
("127.0.0.1", int(dashboard_port)),
http.server.SimpleHTTPRequestHandler,
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/usr/bin/env python3
import http.server
import os
import socketserver
import sys


Expand All @@ -16,5 +17,12 @@ def log_message(self, _format, *_args):
pass


server = http.server.ThreadingHTTPServer(("127.0.0.1", int(sys.argv[2])), Handler)
class Server(http.server.ThreadingHTTPServer):
def server_bind(self):
# Avoid HTTPServer's unnecessary FQDN lookup for a loopback fixture.
socketserver.TCPServer.server_bind(self)
self.server_name, self.server_port = self.server_address[:2]


server = Server(("127.0.0.1", int(sys.argv[2])), Handler)
server.serve_forever()
9 changes: 8 additions & 1 deletion crates/daemon/test-fixtures/managed-resources/mailpit.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,20 @@ def handle(self):
self.request.sendall(b"220 mailpit fixture\r\n")


class HttpServer(http.server.ThreadingHTTPServer):
def server_bind(self):
# Avoid HTTPServer's unnecessary FQDN lookup for a loopback fixture.
socketserver.TCPServer.server_bind(self)
self.server_name, self.server_port = self.server_address[:2]


class TcpServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
allow_reuse_address = True
daemon_threads = True


smtp_server = TcpServer(host_port(smtp), SmtpHandler)
dashboard = http.server.ThreadingHTTPServer(
dashboard = HttpServer(
host_port(listen),
http.server.SimpleHTTPRequestHandler,
)
Expand Down
6 changes: 6 additions & 0 deletions crates/daemon/test-fixtures/managed-resources/rustfs.py.in
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import http.server
import os
import posixpath
import signal
import socketserver
import sys
import threading
import urllib.parse
Expand Down Expand Up @@ -132,6 +133,11 @@ class ConsoleHandler(http.server.BaseHTTPRequestHandler):
class Server(http.server.ThreadingHTTPServer):
allow_reuse_address = True

def server_bind(self):
# Avoid HTTPServer's unnecessary FQDN lookup for a loopback fixture.
socketserver.TCPServer.server_bind(self)
self.server_name, self.server_port = self.server_address[:2]


api = Server(split_address(api_address), RustfsHandler)
console = Server(split_address(console_address), ConsoleHandler)
Expand Down
17 changes: 15 additions & 2 deletions crates/daemon/tests/fixture_contracts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,15 @@ def serve_forever(self, *args, **kwargs):

socketserver.BaseServer.serve_forever = serve_forever
"#;
const FAILING_FQDN_SITECUSTOMIZE: &str = r#"import socket


def getfqdn(_name=""):
raise RuntimeError("fixture attempted an FQDN lookup")


socket.getfqdn = getfqdn
"#;

#[expect(
clippy::disallowed_types,
Expand Down Expand Up @@ -428,7 +437,7 @@ fn single_server_fixture_exits_after_signal_status() -> Result<()> {
}

#[test]
fn multi_server_fixture_exits_after_signal_status() -> Result<()> {
fn multi_server_fixture_avoids_fqdn_lookup_and_exits_after_signal_status() -> Result<()> {
for fixture in [
MultiServerFixture::FakeMailpit,
MultiServerFixture::Mailpit,
Expand Down Expand Up @@ -924,15 +933,19 @@ fn assert_multi_server_fixture_exits_after_signal(
let second_port = second_port_reservation.local_addr()?.port();
let first_address = format!("127.0.0.1:{first_port}");
let second_address = format!("127.0.0.1:{second_port}");
let sitecustomize = tempdir.path().join("sitecustomize.py");
let source = match fixture {
MultiServerFixture::FakeMailpit => FAKE_MAILPIT_FIXTURE.to_owned(),
MultiServerFixture::Mailpit => MAILPIT_FIXTURE.to_owned(),
MultiServerFixture::Rustfs => render_rustfs_fixture(false)?,
};

state::fs::write_sensitive_file(&sitecustomize, FAILING_FQDN_SITECUSTOMIZE)?;
materialize_fixture(&executable, &source)?;
let mut command = FixtureCommand::new(executable.as_std_path());
command.current_dir(tempdir.path());
command
.current_dir(tempdir.path())
.env("PYTHONPATH", tempdir.path());
match fixture {
MultiServerFixture::FakeMailpit => {
command.args([first_port.to_string(), second_port.to_string()]);
Expand Down
Loading
Loading