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
12 changes: 11 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,17 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4
- run: cargo test --locked --all-targets --all-features
# This multi-supervisor test intentionally releases candidate ports before
# launch. Isolate it on macOS, where concurrent process tests can claim or
# starve those ports long enough to exceed its startup assertion.
- if: runner.os == 'macOS'
run: |
cargo test --locked --all-targets --all-features -- \
--skip dynamic_port_bundles_are_distinct_propagated_and_released
cargo test --locked --test dev_services --all-features \
dynamic_port_bundles_are_distinct_propagated_and_released
- if: runner.os != 'macOS'
run: cargo test --locked --all-targets --all-features
- run: scripts/test-dynamic-service-ports

quality:
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,19 @@ aster services logs platform-backend > platform-backend.log

Interactive output honors `$PAGER`, then falls back to `less` or `more`.

List every current allocation for this worktree, including separate supervisor
instances and crash-left listeners:

```console
aster services ports
aster --json services ports
```

Human output maps primary ports to service names and includes all dependency
ports. JSON returns a stable `workspace`/`instances` object; each instance has
its supervisor PID, `active` or `orphaned` status, service mappings, and the
complete named-port map.

Clear stale or orphaned processes from development ports before starting the
stack again:

Expand Down
16 changes: 16 additions & 0 deletions src/cli/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,9 @@ pub enum ServicesCommands {
service: String,
},

/// List ports allocated to running services in this worktree
Ports,

/// Terminate processes listening on configured or specified ports
KillPorts {
/// Port numbers or configured/allocated names (defaults to all known workspace ports)
Expand Down Expand Up @@ -396,6 +399,19 @@ mod tests {
assert!(Cli::try_parse_from(["aster", "services", "logs", "api", "web"]).is_err());
}

#[test]
fn services_ports_accepts_no_arguments() {
let cli = Cli::try_parse_from(["aster", "services", "ports"]).unwrap();
assert!(matches!(
cli.command,
Some(Commands::Services {
command: ServicesCommands::Ports
})
));

assert!(Cli::try_parse_from(["aster", "services", "ports", "web"]).is_err());
}

#[test]
fn skills_is_available_without_a_subcommand() {
let cli = Cli::try_parse_from(["aster", "--skills"]).unwrap();
Expand Down
6 changes: 6 additions & 0 deletions src/cli/skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,8 @@ Interactive `services logs` output honors `$PAGER`, then tries `less` and
Configured development ports can be inspected or cleared:

```console
aster services ports
aster --json services ports
aster services kill-ports --dry-run
aster services kill-ports
aster services kill-ports api web 4011
Expand All @@ -261,6 +263,10 @@ Named ports come from `[dev.ports]` and this worktree's active or crash-left
dynamic allocation manifests. Explicit numeric ports can be inspected or
cleared outside an Aster workspace.

`services ports` reports each supervisor instance separately. Human output
maps primary ports to service names; JSON includes `active`/`orphaned` status,
service mappings, and the complete named-port map for scripting.

## Read target logs and manage the cache

Ordinary completed targets have a separate execution-log store:
Expand Down
2 changes: 2 additions & 0 deletions src/dev/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod log_files;
mod plan;
mod port_allocator;
mod port_cleanup;
mod port_report;
mod process;
mod runner;
mod tls;
Expand All @@ -17,5 +18,6 @@ pub use port_cleanup::{
kill_ports, kill_workspace_ports, resolve_port_selection, resolve_workspace_port_selection,
KillPortsOptions,
};
pub use port_report::{format_workspace_ports, workspace_ports_report, WorkspacePortsReport};
pub use runner::{run_dev, DevOptions};
pub use tls::{serve_tls, setup_tls};
10 changes: 8 additions & 2 deletions src/dev/plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,12 @@ pub fn resolve_dev_plan(
.collect();
let selected_control_port = group_control_port.or(config.control_port.as_deref());
let active_ports = collect_active_ports(config, &selected, selected_control_port)?;
let (ports, port_lease) = allocate_dev_ports(workspace_root, config, &active_ports)?;
let selected_services = selected
.iter()
.map(|name| ((*name).to_string(), config.services[*name].port.clone()))
.collect();
let (ports, port_lease) =
allocate_dev_ports(workspace_root, config, &active_ports, selected_services)?;
let control_port = selected_control_port
.map(|name| {
ports
Expand Down Expand Up @@ -355,6 +360,7 @@ fn allocate_dev_ports(
workspace_root: &Path,
config: &DevWorkspaceConfig,
active: &HashSet<String>,
services: BTreeMap<String, Option<String>>,
) -> Result<(HashMap<String, u16>, PortLease)> {
let file_env = load_env_files(workspace_root, &config.port_env_files)?;
validate_port_offsets(&config.ports)?;
Expand Down Expand Up @@ -433,7 +439,7 @@ fn allocate_dev_ports(
}

let ports = resolve_ports(&config.ports, &file_env, &dynamic_values)?;
Ok((ports, allocator.finish(workspace_root)?))
Ok((ports, allocator.finish(workspace_root, services)?))
}

fn dynamic_root(name: &str, configs: &HashMap<String, DevPortConfig>) -> Result<Option<String>> {
Expand Down
105 changes: 102 additions & 3 deletions src/dev/port_allocator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,23 @@ struct AllocationManifest {
supervisor_pid: u32,
workspace_root: String,
ports: BTreeMap<String, u16>,
#[serde(default)]
services: BTreeMap<String, Option<String>>,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum PortAllocationStatus {
Active,
Orphaned,
}

#[derive(Debug)]
pub(crate) struct WorkspacePortAllocation {
pub supervisor_pid: u32,
pub status: PortAllocationStatus,
pub ports: BTreeMap<String, u16>,
pub services: BTreeMap<String, Option<String>>,
}

impl PortAllocator {
Expand Down Expand Up @@ -129,12 +146,17 @@ impl PortAllocator {
Ok(true)
}

pub(crate) fn finish(self, workspace_root: &Path) -> Result<PortLease> {
pub(crate) fn finish(
self,
workspace_root: &Path,
services: BTreeMap<String, Option<String>>,
) -> Result<PortLease> {
let manifest = AllocationManifest {
version: 1,
supervisor_pid: std::process::id(),
workspace_root: canonical_workspace(workspace_root)?,
ports: self.named_ports,
services,
};
let manifest_path = write_manifest(&self.directory, &manifest)?;
Ok(PortLease {
Expand All @@ -157,6 +179,76 @@ pub(crate) fn workspace_allocated_ports(
Ok(ports)
}

/// Return live allocations for one worktree. Crash-left manifests whose ports
/// are still occupied are retained as orphaned; fully stale manifests are
/// removed and omitted.
pub(crate) fn workspace_port_allocations(
workspace_root: &Path,
) -> Result<Vec<WorkspacePortAllocation>> {
let workspace_root = canonical_workspace(workspace_root)?;
let directory = lease_directory();
if !directory.exists() {
return Ok(Vec::new());
}

let mut allocations = Vec::new();
for (path, manifest) in workspace_manifests(&workspace_root)? {
let mut leased = false;
for port in manifest.ports.values().copied().collect::<HashSet<_>>() {
let file = open_lock(&directory.join(format!("{port}.lock")))?;
match file.try_lock_exclusive() {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
leased = true;
break;
}
Err(error) => {
return Err(error)
.with_context(|| format!("failed to inspect port lease for {port}"));
}
}
}

let occupied = if leased {
false
} else {
manifest
.ports
.values()
.copied()
.map(port_is_available)
.collect::<Result<Vec<_>>>()?
.into_iter()
.any(|available| !available)
};

let status = if leased {
Some(PortAllocationStatus::Active)
} else if occupied {
Some(PortAllocationStatus::Orphaned)
} else {
fs::remove_file(&path).with_context(|| {
format!(
"failed to remove stale allocation manifest {}",
path.display()
)
})?;
None
};

if let Some(status) = status {
allocations.push(WorkspacePortAllocation {
supervisor_pid: manifest.supervisor_pid,
status,
ports: manifest.ports,
services: manifest.services,
});
}
}
allocations.sort_by_key(|allocation| allocation.supervisor_pid);
Ok(allocations)
}

/// Remove crash-left manifests only after every recorded lease is unlocked and
/// every recorded port is free. Active supervisors retain their manifests.
pub(crate) fn prune_workspace_manifests(workspace_root: &Path) -> Result<()> {
Expand Down Expand Up @@ -264,8 +356,15 @@ fn workspace_manifests(workspace_root: &str) -> Result<Vec<(PathBuf, AllocationM
{
continue;
}
let file = File::open(&path)
.with_context(|| format!("failed to open allocation manifest {}", path.display()))?;
let file = match File::open(&path) {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => {
return Err(error).with_context(|| {
format!("failed to open allocation manifest {}", path.display())
});
}
};
let manifest: AllocationManifest = serde_json::from_reader(file)
.with_context(|| format!("failed to parse allocation manifest {}", path.display()))?;
if manifest.version == 1 && manifest.workspace_root == workspace_root {
Expand Down
Loading
Loading