feat: resume時のscope claimを過去の有効scopeに揃える

This commit is contained in:
2026-05-03 17:12:36 +09:00
parent 81ff4c6073
commit 4e48f35e55
16 changed files with 465 additions and 52 deletions
+116 -23
View File
@@ -32,7 +32,7 @@ pub(crate) fn rules_overlap(a: &ScopeRule, b: &ScopeRule) -> bool {
}
/// Does `cover` fully contain `inner`'s claimed paths?
fn covers_fully(cover: &ScopeRule, inner: &ScopeRule) -> bool {
pub(crate) fn covers_fully(cover: &ScopeRule, inner: &ScopeRule) -> bool {
if cover.permission < inner.permission {
return false;
}
@@ -44,8 +44,9 @@ fn covers_fully(cover: &ScopeRule, inner: &ScopeRule) -> bool {
}
/// Check whether `rule` is contained in `parent`'s effective write
/// scope: its allow set covers `rule`, and no child of `parent` has
/// already taken a piece that would overlap `rule`.
/// scope: its allow set covers `rule`, no deny rule caps it, and no
/// child of `parent` has already taken a piece that would overlap
/// `rule`.
pub fn is_within_effective_write(lock: &LockFile, parent: &str, rule: &ScopeRule) -> bool {
let Some(alloc) = lock.find(parent) else {
return false;
@@ -61,6 +62,14 @@ pub fn is_within_effective_write(lock: &LockFile, parent: &str, rule: &ScopeRule
if !covered {
return false;
}
let denied = alloc
.scope_deny
.iter()
.filter(|r| r.permission == Permission::Write)
.any(|r| rules_overlap(r, rule));
if denied {
return false;
}
let child_conflict = lock
.allocations
.iter()
@@ -71,7 +80,14 @@ pub fn is_within_effective_write(lock: &LockFile, parent: &str, rule: &ScopeRule
!child_conflict
}
/// Find the Pod that actually owns a write scope overlapping `rule`.
/// The Pod and rule that actually own a conflicting write scope.
#[derive(Debug, Clone)]
pub struct ConflictOwner {
pub pod_name: String,
pub rule: ScopeRule,
}
/// Find the Pod/rule that actually owns a write scope overlapping `rule`.
///
/// Walks the delegation tree: if an allocation overlaps `rule`, we
/// descend into its children and return the deepest overlapping node
@@ -82,38 +98,47 @@ pub fn find_conflict_owner(
lock: &LockFile,
rule: &ScopeRule,
exempt: Option<&str>,
) -> Option<String> {
) -> Option<ConflictOwner> {
find_conflict_owners(lock, rule, exempt).into_iter().next()
}
/// Find every top-level delegation tree owner that conflicts with `rule`.
pub fn find_conflict_owners(
lock: &LockFile,
rule: &ScopeRule,
exempt: Option<&str>,
) -> Vec<ConflictOwner> {
if rule.permission != Permission::Write {
return None;
return Vec::new();
}
for alloc in lock
.allocations
lock.allocations
.iter()
.filter(|a| a.delegated_from.is_none())
{
if let Some(owner) = find_conflict_in_subtree(lock, alloc, rule) {
if Some(owner.as_str()) == exempt {
continue;
}
return Some(owner);
}
}
None
.filter_map(|alloc| find_conflict_in_subtree(lock, alloc, rule))
.filter(|owner| Some(owner.pod_name.as_str()) != exempt)
.collect()
}
fn find_conflict_in_subtree(
lock: &LockFile,
alloc: &Allocation,
rule: &ScopeRule,
) -> Option<String> {
let overlaps_here = alloc
) -> Option<ConflictOwner> {
let overlapping_rule = alloc
.scope_allow
.iter()
.filter(|r| r.permission == Permission::Write)
.any(|r| rules_overlap(r, rule));
if !overlaps_here {
.find(|r| rules_overlap(r, rule))?;
let fully_denied_here = alloc
.scope_deny
.iter()
.filter(|r| r.permission == Permission::Write)
.any(|r| covers_fully(r, rule));
if fully_denied_here {
return None;
}
for child in lock
.allocations
.iter()
@@ -123,14 +148,17 @@ fn find_conflict_in_subtree(
return Some(owner);
}
}
Some(alloc.pod_name.clone())
Some(ConflictOwner {
pod_name: alloc.pod_name.clone(),
rule: overlapping_rule.clone(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_util::*;
use crate::{ScopeLockError, delegate_scope, register_pod};
use crate::{ScopeLockError, delegate_scope, register_pod, register_pod_with_deny};
use tempfile::TempDir;
#[test]
@@ -200,4 +228,69 @@ mod tests {
other => panic!("expected WriteConflict, got {other:?}"),
}
}
#[test]
fn denied_write_region_is_not_claimed_by_restored_parent() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("pods.json");
let mut g = open_empty(&path);
register_pod_with_deny(
&mut g,
"parent".into(),
std::process::id(),
sock("parent"),
vec![write_rule("/src", true)],
vec![write_rule("/src/core", true)],
sid(),
)
.unwrap();
register_pod(
&mut g,
"child".into(),
std::process::id(),
sock("child"),
vec![write_rule("/src/core", true)],
sid(),
)
.unwrap();
}
#[test]
fn partial_deny_does_not_hide_parent_conflict() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("pods.json");
let mut g = open_empty(&path);
register_pod_with_deny(
&mut g,
"parent".into(),
std::process::id(),
sock("parent"),
vec![write_rule("/src", true)],
vec![write_rule("/src/core", true)],
sid(),
)
.unwrap();
let err = register_pod(
&mut g,
"other".into(),
std::process::id(),
sock("other"),
vec![write_rule("/src", true)],
sid(),
)
.unwrap_err();
match err {
ScopeLockError::WriteConflict {
competitor,
competitor_rule,
..
} => {
assert_eq!(competitor, "parent");
assert_eq!(competitor_rule.target, std::path::PathBuf::from("/src"));
}
other => panic!("expected WriteConflict, got {other:?}"),
}
}
}
+6 -2
View File
@@ -13,8 +13,12 @@ pub enum ScopeLockError {
Io(#[from] io::Error),
#[error("pod name `{0}` is already registered")]
DuplicatePodName(String),
#[error("requested scope `{}` conflicts with pod `{competitor}`", .rule.target.display())]
WriteConflict { competitor: String, rule: ScopeRule },
#[error("requested scope `{}` conflicts with pod `{competitor}` rule `{}`", .rule.target.display(), .competitor_rule.target.display())]
WriteConflict {
competitor: String,
rule: ScopeRule,
competitor_rule: ScopeRule,
},
#[error(
"requested scope `{}` is not within spawner `{spawner}`'s effective scope",
.rule.target.display()
+9 -4
View File
@@ -22,11 +22,16 @@ mod table;
#[cfg(test)]
mod test_util;
pub use conflict::{find_conflict_owner, is_within_effective_write};
pub use conflict::{
ConflictOwner, find_conflict_owner, find_conflict_owners, is_within_effective_write,
};
pub use error::ScopeLockError;
pub use lifecycle::{
ScopeAllocationGuard, SessionLockInfo, adopt_allocation, install_top_level, lookup_session,
update_session,
ScopeAllocationGuard, SessionLockInfo, adopt_allocation, install_top_level,
install_top_level_with_deny, lookup_session, update_session,
};
pub use mutate::{
delegate_scope, reclaim_stale, reclaim_stale_with, register_pod, register_pod_with_deny,
release_pod,
};
pub use mutate::{delegate_scope, reclaim_stale, reclaim_stale_with, register_pod, release_pod};
pub use table::{Allocation, LockFile, LockFileGuard, default_registry_path};
+18 -2
View File
@@ -8,7 +8,7 @@ use manifest::ScopeRule;
use session_store::SessionId;
use crate::error::ScopeLockError;
use crate::mutate::{register_pod, release_pod};
use crate::mutate::release_pod;
use crate::table::{LockFileGuard, default_registry_path};
/// Owned allocation: on drop, opens the lock file and releases this
@@ -46,15 +46,30 @@ pub fn install_top_level(
socket: PathBuf,
scope_allow: Vec<ScopeRule>,
session_id: SessionId,
) -> Result<ScopeAllocationGuard, ScopeLockError> {
install_top_level_with_deny(pod_name, pid, socket, scope_allow, Vec::new(), session_id)
}
/// Open the default lock file, register a top-level Pod with explicit
/// deny rules, and return a guard that will release the allocation on
/// drop.
pub fn install_top_level_with_deny(
pod_name: String,
pid: u32,
socket: PathBuf,
scope_allow: Vec<ScopeRule>,
scope_deny: Vec<ScopeRule>,
session_id: SessionId,
) -> Result<ScopeAllocationGuard, ScopeLockError> {
let lock_path = default_registry_path()?;
let mut guard = LockFileGuard::open(&lock_path)?;
register_pod(
crate::mutate::register_pod_with_deny(
&mut guard,
pod_name.clone(),
pid,
socket,
scope_allow,
scope_deny,
session_id,
)?;
Ok(ScopeAllocationGuard {
@@ -176,6 +191,7 @@ mod tests {
pid: placeholder_pid,
socket: sock(pod_name),
scope_allow: vec![write_rule("/tmp/child", true)],
scope_deny: Vec::new(),
delegated_from: None,
session_id: None,
});
+41 -4
View File
@@ -7,7 +7,7 @@ use std::path::PathBuf;
use manifest::{Permission, ScopeRule};
use session_store::SessionId;
use crate::conflict::{find_conflict_owner, is_within_effective_write};
use crate::conflict::{find_conflict_owner, find_conflict_owners, is_within_effective_write};
use crate::error::ScopeLockError;
use crate::table::{Allocation, LockFileGuard};
@@ -25,6 +25,28 @@ pub fn register_pod(
socket: PathBuf,
scope_allow: Vec<ScopeRule>,
session_id: SessionId,
) -> Result<(), ScopeLockError> {
register_pod_with_deny(
guard,
pod_name,
pid,
socket,
scope_allow,
Vec::new(),
session_id,
)
}
/// Register a top-level Pod with explicit deny rules that reduce the
/// claimed effective write scope.
pub fn register_pod_with_deny(
guard: &mut LockFileGuard,
pod_name: String,
pid: u32,
socket: PathBuf,
scope_allow: Vec<ScopeRule>,
scope_deny: Vec<ScopeRule>,
session_id: SessionId,
) -> Result<(), ScopeLockError> {
reclaim_stale(guard);
if guard.data().find(&pod_name).is_some() {
@@ -41,10 +63,22 @@ pub fn register_pod(
.iter()
.filter(|r| r.permission == Permission::Write)
{
if let Some(competitor) = find_conflict_owner(guard.data(), rule, None) {
let conflicts = find_conflict_owners(guard.data(), rule, None);
let all_denied = !conflicts.is_empty()
&& conflicts.iter().all(|owner| {
scope_deny
.iter()
.filter(|r| r.permission == Permission::Write)
.any(|deny| crate::conflict::covers_fully(deny, &owner.rule))
});
if all_denied {
continue;
}
if let Some(competitor) = conflicts.into_iter().next() {
return Err(ScopeLockError::WriteConflict {
competitor,
competitor: competitor.pod_name,
rule: rule.clone(),
competitor_rule: competitor.rule,
});
}
}
@@ -53,6 +87,7 @@ pub fn register_pod(
pid,
socket,
scope_allow,
scope_deny,
delegated_from: None,
session_id: Some(session_id),
});
@@ -88,8 +123,9 @@ pub fn delegate_scope(
if rule.permission == Permission::Write {
if let Some(competitor) = find_conflict_owner(guard.data(), rule, Some(spawner)) {
return Err(ScopeLockError::WriteConflict {
competitor,
competitor: competitor.pod_name,
rule: rule.clone(),
competitor_rule: competitor.rule,
});
}
}
@@ -99,6 +135,7 @@ pub fn delegate_scope(
pid,
socket,
scope_allow,
scope_deny: Vec::new(),
delegated_from: Some(spawner.into()),
// Pre-reservation. The child fills in its own session_id when
// it calls `adopt_allocation` after the worker is built.
+5
View File
@@ -35,6 +35,11 @@ pub struct Allocation {
pub socket: PathBuf,
/// Allow rules granted to this Pod (write + read).
pub scope_allow: Vec<ScopeRule>,
/// Deny rules that cap this Pod's effective scope. Normally empty for
/// fresh allocations; restored Pods use this to avoid reclaiming
/// previously delegated write regions.
#[serde(default)]
pub scope_deny: Vec<ScopeRule>,
/// Name of the Pod that delegated scope to this one, or `None` for
/// a top-level Pod started directly by a human.
pub delegated_from: Option<String>,