Implement fuse-projfs as an alternative to fuse-overlay for Windows. (#244)
* Implement fuse-projfs to use ProjFS on WIndows. * fix(pi-natives): prevent ProjFS session key mismatch and start race --------- Co-authored-by: can1357 <me@can.ac>
This commit is contained in:
Generated
+1
@@ -2232,6 +2232,7 @@ dependencies = [
|
||||
"tokio-util",
|
||||
"unicode-segmentation",
|
||||
"unicode-width",
|
||||
"windows-sys 0.61.2",
|
||||
"winreg 0.55.0",
|
||||
]
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ Parallel execution framework with specialized agents and real-time streaming:
|
||||
- **Parallel exploration**: Reviewer agent can spawn explore agents for large codebase analysis
|
||||
- **Real-time artifact streaming**: Task outputs stream as they're created, not just at completion
|
||||
- **Full output access**: Read complete subagent output via `agent://<id>` resources when previews truncate
|
||||
- **Isolation backends**: `isolated: true` runs tasks in git worktrees or fuse-overlay filesystems, with patch or branch merge strategies
|
||||
- **Isolation backends**: `isolated: true` runs tasks in git worktrees, Unix fuse-overlay filesystems, or Windows ProjFS (`fuse-projfs`), with patch or branch merge strategies
|
||||
- **Async background jobs**: Background execution with configurable concurrency (up to 100 jobs) and `await` tool for blocking on results
|
||||
- **Agent Control Center**: `/agents` dashboard for managing and creating custom agents
|
||||
- **AI-powered agent creation**: Generate custom agent definitions with the architect model
|
||||
@@ -923,7 +923,7 @@ async:
|
||||
task:
|
||||
eager: false
|
||||
isolation:
|
||||
mode: none # none | worktree | fuse-overlay
|
||||
mode: none # none | worktree | fuse-overlay | fuse-projfs
|
||||
merge: patch # patch | branch
|
||||
```
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"pi-ai": "./src/cli.ts",
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.78",
|
||||
"@anthropic-ai/sdk": "^0.77.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.998",
|
||||
"@bufbuild/protobuf": "^2.11",
|
||||
"@google/genai": "^1.43",
|
||||
@@ -44,7 +44,7 @@
|
||||
"ajv-formats": "^3.0",
|
||||
"openai": "^6.25",
|
||||
"partial-json": "^0.1",
|
||||
"zod": "^4.3",
|
||||
"zod": "4.3.5",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3",
|
||||
@@ -176,7 +176,7 @@
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.78.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-PzQhR715td/m1UaaN5hHXjYB8Gl2lF9UVhrrGrZeysiF6Rb74Wc9GCB8hzLdzmQtBd1qe89F9OptgB9Za1Ib5w=="],
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.77.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-TivlT6nfidz3sOyMF72T2x5AkmHrpT7JgL2e/0HNdh7b24v7JC8cR+rCY/42jA68xIsjmiGQ5IKMsH9feEKh3A=="],
|
||||
|
||||
"@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
|
||||
|
||||
@@ -1002,7 +1002,7 @@
|
||||
|
||||
"yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="],
|
||||
|
||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
"zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
|
||||
|
||||
"@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
|
||||
|
||||
|
||||
@@ -67,5 +67,11 @@ libc = "0.2"
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winreg = "0.55"
|
||||
|
||||
windows-sys = { version = "0.61", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Storage_ProjectedFileSystem",
|
||||
"Win32_System_Com",
|
||||
"Win32_System_LibraryLoader",
|
||||
] }
|
||||
[build-dependencies]
|
||||
napi-build = "2"
|
||||
|
||||
@@ -34,6 +34,7 @@ pub mod html;
|
||||
pub mod image;
|
||||
pub mod keys;
|
||||
pub mod prof;
|
||||
pub mod projfs_overlay;
|
||||
pub mod ps;
|
||||
pub mod pty;
|
||||
pub mod shell;
|
||||
|
||||
@@ -0,0 +1,853 @@
|
||||
//! Windows ProjFS-backed overlay lifecycle for task isolation.
|
||||
|
||||
use napi::bindgen_prelude::*;
|
||||
use napi_derive::napi;
|
||||
|
||||
const PROJFS_UNAVAILABLE_PREFIX: &str = "PROJFS_UNAVAILABLE:";
|
||||
|
||||
#[napi(object)]
|
||||
pub struct ProjfsOverlayProbeResult {
|
||||
pub available: bool,
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
#[napi(js_name = "projfsOverlayProbe")]
|
||||
pub fn projfs_overlay_probe() -> ProjfsOverlayProbeResult {
|
||||
imp::probe()
|
||||
}
|
||||
|
||||
#[napi(js_name = "projfsOverlayStart")]
|
||||
pub fn projfs_overlay_start(lower_root: String, projection_root: String) -> Result<()> {
|
||||
imp::start(&lower_root, &projection_root)
|
||||
}
|
||||
|
||||
#[napi(js_name = "projfsOverlayStop")]
|
||||
pub fn projfs_overlay_stop(projection_root: String) -> Result<()> {
|
||||
imp::stop(&projection_root);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
mod imp {
|
||||
use napi::bindgen_prelude::*;
|
||||
|
||||
use super::{PROJFS_UNAVAILABLE_PREFIX, ProjfsOverlayProbeResult};
|
||||
|
||||
const UNSUPPORTED_REASON: &str = "Windows ProjFS is unavailable on this platform";
|
||||
|
||||
pub fn probe() -> ProjfsOverlayProbeResult {
|
||||
ProjfsOverlayProbeResult { available: false, reason: Some(UNSUPPORTED_REASON.to_string()) }
|
||||
}
|
||||
|
||||
pub fn start(_lower_root: &str, _projection_root: &str) -> Result<()> {
|
||||
Err(Error::from_reason(format!("{PROJFS_UNAVAILABLE_PREFIX} {UNSUPPORTED_REASON}")))
|
||||
}
|
||||
|
||||
pub const fn stop(_projection_root: &str) {}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[allow(
|
||||
clippy::undocumented_unsafe_blocks,
|
||||
reason = "Windows ProjFS bridge is FFI-heavy and safety is validated by pointer and handle \
|
||||
checks"
|
||||
)]
|
||||
mod imp {
|
||||
use std::{
|
||||
collections::{BTreeMap, btree_map::Entry},
|
||||
ffi::{OsStr, OsString, c_void},
|
||||
fs,
|
||||
io::{self, ErrorKind, Read, Seek, SeekFrom},
|
||||
mem,
|
||||
os::windows::{
|
||||
ffi::{OsStrExt, OsStringExt},
|
||||
fs::MetadataExt,
|
||||
},
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, LazyLock},
|
||||
};
|
||||
|
||||
use napi::bindgen_prelude::*;
|
||||
use parking_lot::Mutex;
|
||||
use windows_sys::{
|
||||
Win32::{
|
||||
Foundation::{
|
||||
ERROR_ACCESS_DENIED, ERROR_FILE_NOT_FOUND, ERROR_FILE_SYSTEM_VIRTUALIZATION_BUSY,
|
||||
ERROR_FILE_SYSTEM_VIRTUALIZATION_INVALID_OPERATION,
|
||||
ERROR_FILE_SYSTEM_VIRTUALIZATION_METADATA_CORRUPT,
|
||||
ERROR_FILE_SYSTEM_VIRTUALIZATION_PROVIDER_UNKNOWN,
|
||||
ERROR_FILE_SYSTEM_VIRTUALIZATION_UNAVAILABLE, ERROR_HANDLE_EOF,
|
||||
ERROR_INSUFFICIENT_BUFFER, ERROR_INVALID_FUNCTION, ERROR_INVALID_PARAMETER,
|
||||
ERROR_MOD_NOT_FOUND, ERROR_NOT_SUPPORTED, ERROR_OLD_WIN_VERSION, ERROR_OUTOFMEMORY,
|
||||
ERROR_PROC_NOT_FOUND, FreeLibrary, GetLastError, HMODULE,
|
||||
},
|
||||
Storage::ProjectedFileSystem::{
|
||||
PRJ_CALLBACK_DATA, PRJ_CALLBACKS, PRJ_CB_DATA_FLAG_ENUM_RESTART_SCAN,
|
||||
PRJ_CB_DATA_FLAG_ENUM_RETURN_SINGLE_ENTRY, PRJ_DIR_ENTRY_BUFFER_HANDLE,
|
||||
PRJ_FILE_BASIC_INFO, PRJ_NAMESPACE_VIRTUALIZATION_CONTEXT, PRJ_PLACEHOLDER_INFO,
|
||||
PRJ_STARTVIRTUALIZING_OPTIONS,
|
||||
},
|
||||
System::{
|
||||
Com::CoCreateGuid,
|
||||
LibraryLoader::{GetProcAddress, LoadLibraryW},
|
||||
},
|
||||
},
|
||||
core::{GUID, HRESULT, PCSTR, PCWSTR},
|
||||
};
|
||||
|
||||
use super::{PROJFS_UNAVAILABLE_PREFIX, ProjfsOverlayProbeResult};
|
||||
|
||||
const EMPTY_WIDE: [u16; 1] = [0];
|
||||
const MAX_READ_CHUNK: usize = 1024 * 1024;
|
||||
const E_NOTIMPL: HRESULT = 0x8000_4001_u32 as i32;
|
||||
const E_FAIL: HRESULT = 0x8000_4005_u32 as i32;
|
||||
|
||||
type PrjAllocateAlignedBufferFn =
|
||||
unsafe extern "system" fn(PRJ_NAMESPACE_VIRTUALIZATION_CONTEXT, usize) -> *mut c_void;
|
||||
type PrjFreeAlignedBufferFn = unsafe extern "system" fn(*const c_void);
|
||||
type PrjFileNameCompareFn = unsafe extern "system" fn(PCWSTR, PCWSTR) -> i32;
|
||||
type PrjFileNameMatchFn = unsafe extern "system" fn(PCWSTR, PCWSTR) -> bool;
|
||||
type PrjMarkDirectoryAsPlaceholderFn =
|
||||
unsafe extern "system" fn(PCWSTR, PCWSTR, *const c_void, *const GUID) -> HRESULT;
|
||||
type PrjStartVirtualizingFn = unsafe extern "system" fn(
|
||||
PCWSTR,
|
||||
*const PRJ_CALLBACKS,
|
||||
*const c_void,
|
||||
*const PRJ_STARTVIRTUALIZING_OPTIONS,
|
||||
*mut PRJ_NAMESPACE_VIRTUALIZATION_CONTEXT,
|
||||
) -> HRESULT;
|
||||
type PrjStopVirtualizingFn = unsafe extern "system" fn(PRJ_NAMESPACE_VIRTUALIZATION_CONTEXT);
|
||||
type PrjFillDirEntryBuffer2Fn = unsafe extern "system" fn(
|
||||
PRJ_DIR_ENTRY_BUFFER_HANDLE,
|
||||
PCWSTR,
|
||||
*const PRJ_FILE_BASIC_INFO,
|
||||
*const c_void,
|
||||
) -> HRESULT;
|
||||
type PrjWriteFileDataFn = unsafe extern "system" fn(
|
||||
PRJ_NAMESPACE_VIRTUALIZATION_CONTEXT,
|
||||
*const GUID,
|
||||
*const c_void,
|
||||
u64,
|
||||
u32,
|
||||
) -> HRESULT;
|
||||
type PrjWritePlaceholderInfoFn = unsafe extern "system" fn(
|
||||
PRJ_NAMESPACE_VIRTUALIZATION_CONTEXT,
|
||||
PCWSTR,
|
||||
*const PRJ_PLACEHOLDER_INFO,
|
||||
u32,
|
||||
) -> HRESULT;
|
||||
|
||||
struct ProjfsApi {
|
||||
module: HMODULE,
|
||||
prj_allocate_aligned_buffer: PrjAllocateAlignedBufferFn,
|
||||
prj_free_aligned_buffer: PrjFreeAlignedBufferFn,
|
||||
prj_file_name_compare: PrjFileNameCompareFn,
|
||||
prj_file_name_match: PrjFileNameMatchFn,
|
||||
prj_mark_directory_as_placeholder: PrjMarkDirectoryAsPlaceholderFn,
|
||||
prj_start_virtualizing: PrjStartVirtualizingFn,
|
||||
prj_stop_virtualizing: PrjStopVirtualizingFn,
|
||||
prj_fill_dir_entry_buffer2: PrjFillDirEntryBuffer2Fn,
|
||||
prj_write_file_data: PrjWriteFileDataFn,
|
||||
prj_write_placeholder_info: PrjWritePlaceholderInfoFn,
|
||||
}
|
||||
|
||||
unsafe impl Send for ProjfsApi {}
|
||||
unsafe impl Sync for ProjfsApi {}
|
||||
|
||||
impl Drop for ProjfsApi {
|
||||
fn drop(&mut self) {
|
||||
if !self.module.is_null() {
|
||||
unsafe {
|
||||
FreeLibrary(self.module);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProjfsApi {
|
||||
fn load() -> std::result::Result<Self, String> {
|
||||
let library_name = to_wide(OsStr::new("ProjectedFSLib.dll"));
|
||||
let module = unsafe { LoadLibraryW(library_name.as_ptr()) };
|
||||
if module.is_null() {
|
||||
let win32 = unsafe { GetLastError() };
|
||||
return Err(format!("ProjectedFSLib.dll could not be loaded (win32={win32})"));
|
||||
}
|
||||
|
||||
macro_rules! load_symbol {
|
||||
($name:literal, $ty:ty) => {{
|
||||
let proc = unsafe {
|
||||
GetProcAddress(module, concat!($name, "\0").as_ptr().cast::<u8>() as PCSTR)
|
||||
};
|
||||
let Some(proc) = proc else {
|
||||
unsafe {
|
||||
FreeLibrary(module);
|
||||
}
|
||||
return Err(format!("ProjectedFSLib.dll missing symbol {}", $name));
|
||||
};
|
||||
unsafe { mem::transmute::<unsafe extern "system" fn() -> isize, $ty>(proc) }
|
||||
}};
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
module,
|
||||
prj_allocate_aligned_buffer: load_symbol!(
|
||||
"PrjAllocateAlignedBuffer",
|
||||
PrjAllocateAlignedBufferFn
|
||||
),
|
||||
prj_free_aligned_buffer: load_symbol!("PrjFreeAlignedBuffer", PrjFreeAlignedBufferFn),
|
||||
prj_file_name_compare: load_symbol!("PrjFileNameCompare", PrjFileNameCompareFn),
|
||||
prj_file_name_match: load_symbol!("PrjFileNameMatch", PrjFileNameMatchFn),
|
||||
prj_mark_directory_as_placeholder: load_symbol!(
|
||||
"PrjMarkDirectoryAsPlaceholder",
|
||||
PrjMarkDirectoryAsPlaceholderFn
|
||||
),
|
||||
prj_start_virtualizing: load_symbol!("PrjStartVirtualizing", PrjStartVirtualizingFn),
|
||||
prj_stop_virtualizing: load_symbol!("PrjStopVirtualizing", PrjStopVirtualizingFn),
|
||||
prj_fill_dir_entry_buffer2: load_symbol!(
|
||||
"PrjFillDirEntryBuffer2",
|
||||
PrjFillDirEntryBuffer2Fn
|
||||
),
|
||||
prj_write_file_data: load_symbol!("PrjWriteFileData", PrjWriteFileDataFn),
|
||||
prj_write_placeholder_info: load_symbol!(
|
||||
"PrjWritePlaceholderInfo",
|
||||
PrjWritePlaceholderInfoFn
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct DirectoryEntry {
|
||||
name_wide: Vec<u16>,
|
||||
basic_info: PRJ_FILE_BASIC_INFO,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DirectoryEnumeration {
|
||||
entries: Vec<DirectoryEntry>,
|
||||
cursor: usize,
|
||||
search_expression: Option<Vec<u16>>,
|
||||
}
|
||||
|
||||
struct ProviderContext {
|
||||
lower_root: PathBuf,
|
||||
api: Arc<ProjfsApi>,
|
||||
enumerations: Mutex<BTreeMap<u128, DirectoryEnumeration>>,
|
||||
}
|
||||
|
||||
struct ProjfsSession {
|
||||
virtualization_context: PRJ_NAMESPACE_VIRTUALIZATION_CONTEXT,
|
||||
provider_context: *mut ProviderContext,
|
||||
callbacks: Box<PRJ_CALLBACKS>,
|
||||
api_handle: Arc<ProjfsApi>,
|
||||
}
|
||||
|
||||
// SAFETY: Session ownership is synchronized through `PROJFS_SESSIONS`; raw
|
||||
// pointers are only created/freed on controlled start/stop paths and not
|
||||
// concurrently aliased.
|
||||
unsafe impl Send for ProjfsSession {}
|
||||
|
||||
enum ProjfsSessionState {
|
||||
Starting,
|
||||
Active(ProjfsSession),
|
||||
}
|
||||
|
||||
static PROJFS_SESSIONS: LazyLock<Mutex<BTreeMap<String, ProjfsSessionState>>> =
|
||||
LazyLock::new(|| Mutex::new(BTreeMap::new()));
|
||||
|
||||
pub fn probe() -> ProjfsOverlayProbeResult {
|
||||
match ProjfsApi::load() {
|
||||
Ok(_) => ProjfsOverlayProbeResult { available: true, reason: None },
|
||||
Err(reason) => ProjfsOverlayProbeResult { available: false, reason: Some(reason) },
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(lower_root: &str, projection_root: &str) -> Result<()> {
|
||||
let api = Arc::new(ProjfsApi::load().map_err(unavailable_error)?);
|
||||
let lower_root_path = resolve_existing_dir(lower_root)?;
|
||||
let projection_root_path = resolve_projection_root(projection_root)?;
|
||||
let projection_key = normalize_session_key(&projection_root_path);
|
||||
|
||||
{
|
||||
let mut sessions = PROJFS_SESSIONS.lock();
|
||||
if sessions.contains_key(&projection_key) {
|
||||
return Err(Error::from_reason(format!(
|
||||
"ProjFS overlay is already active for {}",
|
||||
projection_root_path.display()
|
||||
)));
|
||||
}
|
||||
sessions.insert(projection_key.clone(), ProjfsSessionState::Starting);
|
||||
}
|
||||
|
||||
let mut instance_id = GUID::default();
|
||||
let guid_hr = unsafe { CoCreateGuid(&raw mut instance_id) };
|
||||
if is_failed(guid_hr) {
|
||||
PROJFS_SESSIONS.lock().remove(&projection_key);
|
||||
return Err(Error::from_reason(format!(
|
||||
"Unable to create ProjFS instance identifier ({})",
|
||||
format_hresult(guid_hr)
|
||||
)));
|
||||
}
|
||||
|
||||
let root_wide = to_wide(projection_root_path.as_os_str());
|
||||
let mark_hr = unsafe {
|
||||
(api.prj_mark_directory_as_placeholder)(
|
||||
root_wide.as_ptr(),
|
||||
std::ptr::null(),
|
||||
std::ptr::null(),
|
||||
&raw const instance_id,
|
||||
)
|
||||
};
|
||||
if is_failed(mark_hr) {
|
||||
PROJFS_SESSIONS.lock().remove(&projection_key);
|
||||
return Err(classify_start_error("mark placeholder root", mark_hr));
|
||||
}
|
||||
|
||||
let provider_context = Box::new(ProviderContext {
|
||||
lower_root: lower_root_path,
|
||||
api: api.clone(),
|
||||
enumerations: Mutex::new(BTreeMap::new()),
|
||||
});
|
||||
let provider_context_ptr = Box::into_raw(provider_context);
|
||||
let callbacks = Box::new(PRJ_CALLBACKS {
|
||||
StartDirectoryEnumerationCallback: Some(start_directory_enumeration_callback),
|
||||
EndDirectoryEnumerationCallback: Some(end_directory_enumeration_callback),
|
||||
GetDirectoryEnumerationCallback: Some(get_directory_enumeration_callback),
|
||||
GetPlaceholderInfoCallback: Some(get_placeholder_info_callback),
|
||||
GetFileDataCallback: Some(get_file_data_callback),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let mut virtualization_context: PRJ_NAMESPACE_VIRTUALIZATION_CONTEXT = std::ptr::null_mut();
|
||||
let start_hr = unsafe {
|
||||
(api.prj_start_virtualizing)(
|
||||
root_wide.as_ptr(),
|
||||
callbacks.as_ref(),
|
||||
provider_context_ptr.cast::<c_void>(),
|
||||
std::ptr::null(),
|
||||
&raw mut virtualization_context,
|
||||
)
|
||||
};
|
||||
if is_failed(start_hr) {
|
||||
PROJFS_SESSIONS.lock().remove(&projection_key);
|
||||
// SAFETY: `provider_context_ptr` comes from `Box::into_raw` above and start
|
||||
// failed, so ProjFS never took ownership and this function remains the sole
|
||||
// owner.
|
||||
unsafe {
|
||||
drop(Box::from_raw(provider_context_ptr));
|
||||
}
|
||||
return Err(classify_start_error("start virtualization", start_hr));
|
||||
}
|
||||
|
||||
let started_session = ProjfsSession {
|
||||
virtualization_context,
|
||||
provider_context: provider_context_ptr,
|
||||
callbacks,
|
||||
api_handle: api,
|
||||
};
|
||||
|
||||
let mut sessions = PROJFS_SESSIONS.lock();
|
||||
match sessions.entry(projection_key) {
|
||||
Entry::Occupied(mut entry) => {
|
||||
if matches!(entry.get(), ProjfsSessionState::Starting) {
|
||||
entry.insert(ProjfsSessionState::Active(started_session));
|
||||
Ok(())
|
||||
} else {
|
||||
drop(entry);
|
||||
drop(sessions);
|
||||
stop_projfs_session(started_session);
|
||||
Err(Error::from_reason(format!(
|
||||
"ProjFS overlay is already active for {}",
|
||||
projection_root_path.display()
|
||||
)))
|
||||
}
|
||||
},
|
||||
Entry::Vacant(_) => {
|
||||
drop(sessions);
|
||||
stop_projfs_session(started_session);
|
||||
Err(Error::from_reason(format!(
|
||||
"ProjFS overlay start was canceled for {}",
|
||||
projection_root_path.display()
|
||||
)))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stop(projection_root: &str) {
|
||||
let projection_root_path = resolve_absolute_path(Path::new(projection_root));
|
||||
let projection_root_path =
|
||||
fs::canonicalize(&projection_root_path).unwrap_or(projection_root_path);
|
||||
let key = normalize_session_key(&projection_root_path);
|
||||
let session_state = PROJFS_SESSIONS.lock().remove(&key);
|
||||
let Some(ProjfsSessionState::Active(session)) = session_state else {
|
||||
return;
|
||||
};
|
||||
|
||||
stop_projfs_session(session);
|
||||
}
|
||||
|
||||
fn stop_projfs_session(session: ProjfsSession) {
|
||||
// SAFETY: The session holds the live ProjFS context and provider pointer
|
||||
// created in `start`; this function consumes ownership and runs the
|
||||
// corresponding one-time teardown.
|
||||
unsafe {
|
||||
(session.api_handle.prj_stop_virtualizing)(session.virtualization_context);
|
||||
drop(Box::from_raw(session.provider_context));
|
||||
}
|
||||
drop(session.callbacks);
|
||||
}
|
||||
|
||||
unsafe extern "system" fn start_directory_enumeration_callback(
|
||||
callback_data: *const PRJ_CALLBACK_DATA,
|
||||
enumeration_id: *const GUID,
|
||||
) -> HRESULT {
|
||||
let Ok((callback_data, context)) = callback_context(callback_data) else {
|
||||
return hresult_from_win32(ERROR_INVALID_PARAMETER);
|
||||
};
|
||||
if enumeration_id.is_null() {
|
||||
return hresult_from_win32(ERROR_INVALID_PARAMETER);
|
||||
}
|
||||
|
||||
let target_path = callback_relative_path(callback_data);
|
||||
let entries = match list_directory_entries(context, &target_path) {
|
||||
Ok(entries) => entries,
|
||||
Err(err) => return io_error_to_hresult(&err),
|
||||
};
|
||||
|
||||
let mut enumerations = context.enumerations.lock();
|
||||
enumerations.insert(guid_to_u128(unsafe { &*enumeration_id }), DirectoryEnumeration {
|
||||
entries,
|
||||
cursor: 0,
|
||||
search_expression: None,
|
||||
});
|
||||
0
|
||||
}
|
||||
|
||||
unsafe extern "system" fn end_directory_enumeration_callback(
|
||||
callback_data: *const PRJ_CALLBACK_DATA,
|
||||
enumeration_id: *const GUID,
|
||||
) -> HRESULT {
|
||||
let Ok((_, context)) = callback_context(callback_data) else {
|
||||
return hresult_from_win32(ERROR_INVALID_PARAMETER);
|
||||
};
|
||||
if enumeration_id.is_null() {
|
||||
return hresult_from_win32(ERROR_INVALID_PARAMETER);
|
||||
}
|
||||
|
||||
context
|
||||
.enumerations
|
||||
.lock()
|
||||
.remove(&guid_to_u128(unsafe { &*enumeration_id }));
|
||||
0
|
||||
}
|
||||
|
||||
unsafe extern "system" fn get_directory_enumeration_callback(
|
||||
callback_data: *const PRJ_CALLBACK_DATA,
|
||||
enumeration_id: *const GUID,
|
||||
search_expression: PCWSTR,
|
||||
dir_entry_buffer_handle: PRJ_DIR_ENTRY_BUFFER_HANDLE,
|
||||
) -> HRESULT {
|
||||
let Ok((callback_data, context)) = callback_context(callback_data) else {
|
||||
return hresult_from_win32(ERROR_INVALID_PARAMETER);
|
||||
};
|
||||
if enumeration_id.is_null() {
|
||||
return hresult_from_win32(ERROR_INVALID_PARAMETER);
|
||||
}
|
||||
|
||||
let enum_key = guid_to_u128(unsafe { &*enumeration_id });
|
||||
let mut enumerations = context.enumerations.lock();
|
||||
let Some(enumeration) = enumerations.get_mut(&enum_key) else {
|
||||
return 0;
|
||||
};
|
||||
|
||||
if callback_data.Flags & PRJ_CB_DATA_FLAG_ENUM_RESTART_SCAN != 0 {
|
||||
enumeration.cursor = 0;
|
||||
enumeration.search_expression = None;
|
||||
}
|
||||
if !search_expression.is_null() {
|
||||
let expression = read_pcwstr(search_expression);
|
||||
if !expression.is_empty() {
|
||||
let mut with_nul = expression;
|
||||
with_nul.push(0);
|
||||
enumeration.search_expression = Some(with_nul);
|
||||
}
|
||||
}
|
||||
|
||||
while enumeration.cursor < enumeration.entries.len() {
|
||||
let entry = &enumeration.entries[enumeration.cursor];
|
||||
let matched = if let Some(expression) = &enumeration.search_expression {
|
||||
unsafe {
|
||||
(context.api.prj_file_name_match)(entry.name_wide.as_ptr(), expression.as_ptr())
|
||||
}
|
||||
} else {
|
||||
true
|
||||
};
|
||||
|
||||
if matched {
|
||||
let hr = unsafe {
|
||||
(context.api.prj_fill_dir_entry_buffer2)(
|
||||
dir_entry_buffer_handle,
|
||||
entry.name_wide.as_ptr(),
|
||||
&raw const entry.basic_info,
|
||||
std::ptr::null(),
|
||||
)
|
||||
};
|
||||
if is_failed(hr) {
|
||||
if win32_from_hresult(hr) == Some(ERROR_INSUFFICIENT_BUFFER) {
|
||||
break;
|
||||
}
|
||||
return hr;
|
||||
}
|
||||
}
|
||||
|
||||
enumeration.cursor += 1;
|
||||
if callback_data.Flags & PRJ_CB_DATA_FLAG_ENUM_RETURN_SINGLE_ENTRY != 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
0
|
||||
}
|
||||
|
||||
unsafe extern "system" fn get_placeholder_info_callback(
|
||||
callback_data: *const PRJ_CALLBACK_DATA,
|
||||
) -> HRESULT {
|
||||
let Ok((callback_data, context)) = callback_context(callback_data) else {
|
||||
return hresult_from_win32(ERROR_INVALID_PARAMETER);
|
||||
};
|
||||
|
||||
let relative_path = callback_relative_path(callback_data);
|
||||
let source_path = context.lower_root.join(relative_path);
|
||||
let metadata = match fs::symlink_metadata(&source_path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(err) => return io_error_to_hresult(&err),
|
||||
};
|
||||
|
||||
let placeholder_info =
|
||||
PRJ_PLACEHOLDER_INFO { FileBasicInfo: to_basic_info(&metadata), ..Default::default() };
|
||||
|
||||
let destination = if callback_data.FilePathName.is_null() {
|
||||
EMPTY_WIDE.as_ptr()
|
||||
} else {
|
||||
callback_data.FilePathName
|
||||
};
|
||||
|
||||
unsafe {
|
||||
(context.api.prj_write_placeholder_info)(
|
||||
callback_data.NamespaceVirtualizationContext,
|
||||
destination,
|
||||
&raw const placeholder_info,
|
||||
mem::size_of::<PRJ_PLACEHOLDER_INFO>() as u32,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "system" fn get_file_data_callback(
|
||||
callback_data: *const PRJ_CALLBACK_DATA,
|
||||
byte_offset: u64,
|
||||
length: u32,
|
||||
) -> HRESULT {
|
||||
let Ok((callback_data, context)) = callback_context(callback_data) else {
|
||||
return hresult_from_win32(ERROR_INVALID_PARAMETER);
|
||||
};
|
||||
if length == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let relative_path = callback_relative_path(callback_data);
|
||||
let source_path = context.lower_root.join(relative_path);
|
||||
let mut file = match fs::File::open(&source_path) {
|
||||
Ok(file) => file,
|
||||
Err(err) => return io_error_to_hresult(&err),
|
||||
};
|
||||
if let Err(err) = file.seek(SeekFrom::Start(byte_offset)) {
|
||||
return io_error_to_hresult(&err);
|
||||
}
|
||||
|
||||
let chunk_size = usize::min(length as usize, MAX_READ_CHUNK);
|
||||
let aligned_ptr = unsafe {
|
||||
(context.api.prj_allocate_aligned_buffer)(
|
||||
callback_data.NamespaceVirtualizationContext,
|
||||
chunk_size,
|
||||
)
|
||||
};
|
||||
if aligned_ptr.is_null() {
|
||||
return hresult_from_win32(ERROR_OUTOFMEMORY);
|
||||
}
|
||||
let mut aligned_buffer = AlignedBuffer::new(context.api.clone(), aligned_ptr, chunk_size);
|
||||
|
||||
let mut written = 0usize;
|
||||
while written < length as usize {
|
||||
let to_read = usize::min(aligned_buffer.len(), length as usize - written);
|
||||
if let Err(err) = file.read_exact(&mut aligned_buffer.as_mut_slice()[..to_read]) {
|
||||
return io_error_to_hresult(&err);
|
||||
}
|
||||
|
||||
let hr = unsafe {
|
||||
(context.api.prj_write_file_data)(
|
||||
callback_data.NamespaceVirtualizationContext,
|
||||
&raw const callback_data.DataStreamId,
|
||||
aligned_buffer.as_mut_slice().as_ptr().cast::<c_void>(),
|
||||
byte_offset + written as u64,
|
||||
to_read as u32,
|
||||
)
|
||||
};
|
||||
if is_failed(hr) {
|
||||
return hr;
|
||||
}
|
||||
|
||||
written += to_read;
|
||||
}
|
||||
|
||||
0
|
||||
}
|
||||
|
||||
struct AlignedBuffer {
|
||||
api: Arc<ProjfsApi>,
|
||||
ptr: *mut c_void,
|
||||
len: usize,
|
||||
}
|
||||
|
||||
impl AlignedBuffer {
|
||||
const fn new(api: Arc<ProjfsApi>, ptr: *mut c_void, len: usize) -> Self {
|
||||
Self { api, ptr, len }
|
||||
}
|
||||
|
||||
const fn len(&self) -> usize {
|
||||
self.len
|
||||
}
|
||||
|
||||
const fn as_mut_slice(&mut self) -> &mut [u8] {
|
||||
unsafe { std::slice::from_raw_parts_mut(self.ptr.cast::<u8>(), self.len) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AlignedBuffer {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
(self.api.prj_free_aligned_buffer)(self.ptr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn callback_context(
|
||||
callback_data: *const PRJ_CALLBACK_DATA,
|
||||
) -> std::result::Result<(&'static PRJ_CALLBACK_DATA, &'static ProviderContext), HRESULT> {
|
||||
if callback_data.is_null() {
|
||||
return Err(hresult_from_win32(ERROR_INVALID_PARAMETER));
|
||||
}
|
||||
let callback_data = unsafe { &*callback_data };
|
||||
if callback_data.InstanceContext.is_null() {
|
||||
return Err(hresult_from_win32(ERROR_INVALID_PARAMETER));
|
||||
}
|
||||
let context = unsafe { &*(callback_data.InstanceContext.cast::<ProviderContext>()) };
|
||||
Ok((callback_data, context))
|
||||
}
|
||||
|
||||
fn list_directory_entries(
|
||||
context: &ProviderContext,
|
||||
relative_path: &Path,
|
||||
) -> io::Result<Vec<DirectoryEntry>> {
|
||||
let source_dir = context.lower_root.join(relative_path);
|
||||
let mut entries = Vec::new();
|
||||
for entry in fs::read_dir(&source_dir)? {
|
||||
let entry = entry?;
|
||||
let metadata = entry.metadata()?;
|
||||
let name = entry.file_name();
|
||||
let mut name_wide = to_wide(name.as_os_str());
|
||||
if name_wide.is_empty() {
|
||||
continue;
|
||||
}
|
||||
entries.push(DirectoryEntry {
|
||||
name_wide: {
|
||||
name_wide.shrink_to_fit();
|
||||
name_wide
|
||||
},
|
||||
basic_info: to_basic_info(&metadata),
|
||||
});
|
||||
}
|
||||
|
||||
entries.sort_by(|left, right| {
|
||||
let compare = unsafe {
|
||||
(context.api.prj_file_name_compare)(left.name_wide.as_ptr(), right.name_wide.as_ptr())
|
||||
};
|
||||
compare.cmp(&0)
|
||||
});
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn to_basic_info(metadata: &fs::Metadata) -> PRJ_FILE_BASIC_INFO {
|
||||
PRJ_FILE_BASIC_INFO {
|
||||
IsDirectory: metadata.is_dir(),
|
||||
FileSize: metadata.file_size() as i64,
|
||||
CreationTime: metadata.creation_time() as i64,
|
||||
LastAccessTime: metadata.last_access_time() as i64,
|
||||
LastWriteTime: metadata.last_write_time() as i64,
|
||||
ChangeTime: metadata.last_write_time() as i64,
|
||||
FileAttributes: metadata.file_attributes(),
|
||||
}
|
||||
}
|
||||
|
||||
fn callback_relative_path(callback_data: &PRJ_CALLBACK_DATA) -> PathBuf {
|
||||
if callback_data.FilePathName.is_null() {
|
||||
return PathBuf::new();
|
||||
}
|
||||
let raw = read_pcwstr(callback_data.FilePathName);
|
||||
if raw.is_empty() {
|
||||
return PathBuf::new();
|
||||
}
|
||||
PathBuf::from(OsString::from_wide(&raw))
|
||||
}
|
||||
|
||||
fn read_pcwstr(value: PCWSTR) -> Vec<u16> {
|
||||
if value.is_null() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut len = 0usize;
|
||||
unsafe {
|
||||
while *value.add(len) != 0 {
|
||||
len += 1;
|
||||
}
|
||||
std::slice::from_raw_parts(value, len).to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_existing_dir(path: &str) -> Result<PathBuf> {
|
||||
let resolved = resolve_absolute_path(Path::new(path));
|
||||
let metadata = fs::metadata(&resolved).map_err(|err| {
|
||||
Error::from_reason(format!("Invalid ProjFS lower root {}: {err}", resolved.display()))
|
||||
})?;
|
||||
if !metadata.is_dir() {
|
||||
return Err(Error::from_reason(format!(
|
||||
"Invalid ProjFS lower root {}: path is not a directory",
|
||||
resolved.display()
|
||||
)));
|
||||
}
|
||||
Ok(fs::canonicalize(&resolved).unwrap_or(resolved))
|
||||
}
|
||||
|
||||
fn resolve_projection_root(path: &str) -> Result<PathBuf> {
|
||||
let resolved = resolve_absolute_path(Path::new(path));
|
||||
fs::create_dir_all(&resolved).map_err(|err| {
|
||||
Error::from_reason(format!(
|
||||
"Unable to create ProjFS projection root {}: {err}",
|
||||
resolved.display()
|
||||
))
|
||||
})?;
|
||||
let metadata = fs::metadata(&resolved).map_err(|err| {
|
||||
Error::from_reason(format!(
|
||||
"Unable to access ProjFS projection root {}: {err}",
|
||||
resolved.display()
|
||||
))
|
||||
})?;
|
||||
if !metadata.is_dir() {
|
||||
return Err(Error::from_reason(format!(
|
||||
"Invalid ProjFS projection root {}: path is not a directory",
|
||||
resolved.display()
|
||||
)));
|
||||
}
|
||||
Ok(fs::canonicalize(&resolved).unwrap_or(resolved))
|
||||
}
|
||||
|
||||
fn resolve_absolute_path(path: &Path) -> PathBuf {
|
||||
if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir().map_or_else(|_| path.to_path_buf(), |cwd| cwd.join(path))
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_session_key(path: &Path) -> String {
|
||||
path.to_string_lossy().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn to_wide(value: &OsStr) -> Vec<u16> {
|
||||
let mut encoded: Vec<u16> = value.encode_wide().collect();
|
||||
encoded.push(0);
|
||||
encoded
|
||||
}
|
||||
|
||||
fn unavailable_error(reason: String) -> Error {
|
||||
Error::from_reason(format!("{PROJFS_UNAVAILABLE_PREFIX} {reason}"))
|
||||
}
|
||||
|
||||
fn classify_start_error(phase: &str, hr: HRESULT) -> Error {
|
||||
let detail = format!("ProjFS {phase} failed ({})", format_hresult(hr));
|
||||
if is_unavailable_hresult(hr) {
|
||||
return unavailable_error(detail);
|
||||
}
|
||||
Error::from_reason(detail)
|
||||
}
|
||||
|
||||
const fn is_unavailable_hresult(hr: HRESULT) -> bool {
|
||||
if hr == E_NOTIMPL {
|
||||
return true;
|
||||
}
|
||||
let Some(win32) = win32_from_hresult(hr) else {
|
||||
return false;
|
||||
};
|
||||
matches!(
|
||||
win32,
|
||||
ERROR_NOT_SUPPORTED
|
||||
| ERROR_INVALID_FUNCTION
|
||||
| ERROR_MOD_NOT_FOUND
|
||||
| ERROR_PROC_NOT_FOUND
|
||||
| ERROR_OLD_WIN_VERSION
|
||||
| ERROR_FILE_SYSTEM_VIRTUALIZATION_UNAVAILABLE
|
||||
| ERROR_FILE_SYSTEM_VIRTUALIZATION_PROVIDER_UNKNOWN
|
||||
| ERROR_FILE_SYSTEM_VIRTUALIZATION_METADATA_CORRUPT
|
||||
| ERROR_FILE_SYSTEM_VIRTUALIZATION_INVALID_OPERATION
|
||||
| ERROR_FILE_SYSTEM_VIRTUALIZATION_BUSY
|
||||
)
|
||||
}
|
||||
|
||||
fn format_hresult(hr: HRESULT) -> String {
|
||||
if let Some(win32) = win32_from_hresult(hr) {
|
||||
format!("HRESULT=0x{:08X}, win32={win32}", hr as u32)
|
||||
} else {
|
||||
format!("HRESULT=0x{:08X}", hr as u32)
|
||||
}
|
||||
}
|
||||
|
||||
const fn win32_from_hresult(hr: HRESULT) -> Option<u32> {
|
||||
let raw = hr as u32;
|
||||
if (raw & 0xffff_0000) == 0x8007_0000 {
|
||||
Some(raw & 0xffff)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
const fn hresult_from_win32(code: u32) -> HRESULT {
|
||||
if code == 0 {
|
||||
0
|
||||
} else {
|
||||
((code & 0x0000_ffff) | 0x8007_0000) as i32
|
||||
}
|
||||
}
|
||||
|
||||
fn io_error_to_hresult(err: &io::Error) -> HRESULT {
|
||||
if let Some(code) = err.raw_os_error()
|
||||
&& code > 0
|
||||
{
|
||||
return hresult_from_win32(code as u32);
|
||||
}
|
||||
|
||||
match err.kind() {
|
||||
ErrorKind::NotFound => hresult_from_win32(ERROR_FILE_NOT_FOUND),
|
||||
ErrorKind::PermissionDenied => hresult_from_win32(ERROR_ACCESS_DENIED),
|
||||
ErrorKind::UnexpectedEof => hresult_from_win32(ERROR_HANDLE_EOF),
|
||||
ErrorKind::OutOfMemory => hresult_from_win32(ERROR_OUTOFMEMORY),
|
||||
_ => E_FAIL,
|
||||
}
|
||||
}
|
||||
|
||||
fn guid_to_u128(guid: &GUID) -> u128 {
|
||||
let bytes: [u8; 16] = unsafe { mem::transmute(*guid) };
|
||||
u128::from_le_bytes(bytes)
|
||||
}
|
||||
|
||||
const fn is_failed(hr: HRESULT) -> bool {
|
||||
hr < 0
|
||||
}
|
||||
}
|
||||
+20
-14
@@ -131,7 +131,7 @@ mod platform {
|
||||
use smallvec::SmallVec;
|
||||
|
||||
#[repr(C)]
|
||||
#[allow(non_snake_case)]
|
||||
#[allow(non_snake_case, reason = "Windows PROCESSENTRY32W field names must match Win32 ABI")]
|
||||
struct PROCESSENTRY32W {
|
||||
dwSize: u32,
|
||||
cntUsage: u32,
|
||||
@@ -145,25 +145,27 @@ mod platform {
|
||||
szExeFile: [u16; 260],
|
||||
}
|
||||
|
||||
type HANDLE = *mut std::ffi::c_void;
|
||||
const INVALID_HANDLE_VALUE: HANDLE = -1isize as HANDLE;
|
||||
type Handle = *mut std::ffi::c_void;
|
||||
const INVALID_HANDLE_VALUE: Handle = -1isize as Handle;
|
||||
const TH32CS_SNAPPROCESS: u32 = 0x00000002;
|
||||
const PROCESS_TERMINATE: u32 = 0x0001;
|
||||
|
||||
#[link(name = "kernel32")]
|
||||
unsafe extern "system" {
|
||||
fn CreateToolhelp32Snapshot(dwFlags: u32, th32ProcessID: u32) -> HANDLE;
|
||||
fn Process32FirstW(hSnapshot: HANDLE, lppe: *mut PROCESSENTRY32W) -> i32;
|
||||
fn Process32NextW(hSnapshot: HANDLE, lppe: *mut PROCESSENTRY32W) -> i32;
|
||||
fn CloseHandle(hObject: HANDLE) -> i32;
|
||||
fn OpenProcess(dwDesiredAccess: u32, bInheritHandle: i32, dwProcessId: u32) -> HANDLE;
|
||||
fn TerminateProcess(hProcess: HANDLE, uExitCode: u32) -> i32;
|
||||
fn CreateToolhelp32Snapshot(dwFlags: u32, th32ProcessID: u32) -> Handle;
|
||||
fn Process32FirstW(hSnapshot: Handle, lppe: *mut PROCESSENTRY32W) -> i32;
|
||||
fn Process32NextW(hSnapshot: Handle, lppe: *mut PROCESSENTRY32W) -> i32;
|
||||
fn CloseHandle(hObject: Handle) -> i32;
|
||||
fn OpenProcess(dwDesiredAccess: u32, bInheritHandle: i32, dwProcessId: u32) -> Handle;
|
||||
fn TerminateProcess(hProcess: Handle, uExitCode: u32) -> i32;
|
||||
}
|
||||
|
||||
/// Build a map of parent_pid -> [child_pids] for all processes.
|
||||
/// Build a map of `parent_pid` -> [`child_pids`] for all processes.
|
||||
fn build_process_tree() -> HashMap<u32, SmallVec<[u32; 4]>> {
|
||||
let mut tree: HashMap<u32, SmallVec<[u32; 4]>> = HashMap::new();
|
||||
|
||||
// SAFETY: Toolhelp snapshot APIs are called with initialized structs and valid
|
||||
// handles.
|
||||
unsafe {
|
||||
let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
|
||||
if snapshot == INVALID_HANDLE_VALUE {
|
||||
@@ -173,14 +175,14 @@ mod platform {
|
||||
let mut entry: PROCESSENTRY32W = mem::zeroed();
|
||||
entry.dwSize = mem::size_of::<PROCESSENTRY32W>() as u32;
|
||||
|
||||
if Process32FirstW(snapshot, &mut entry) != 0 {
|
||||
if Process32FirstW(snapshot, &raw mut entry) != 0 {
|
||||
loop {
|
||||
tree
|
||||
.entry(entry.th32ParentProcessID)
|
||||
.or_default()
|
||||
.push(entry.th32ProcessID);
|
||||
|
||||
if Process32NextW(snapshot, &mut entry) == 0 {
|
||||
if Process32NextW(snapshot, &raw mut entry) == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -215,6 +217,8 @@ mod platform {
|
||||
/// Terminate `pid` (Windows ignores `signal`).
|
||||
/// Returns true when the process is terminated.
|
||||
pub fn kill_pid(pid: i32, _signal: i32) -> bool {
|
||||
// SAFETY: OpenProcess/TerminateProcess are called with kernel-provided process
|
||||
// IDs and handles are always closed.
|
||||
unsafe {
|
||||
let handle = OpenProcess(PROCESS_TERMINATE, 0, pid as u32);
|
||||
if handle.is_null() || handle == INVALID_HANDLE_VALUE {
|
||||
@@ -228,13 +232,13 @@ mod platform {
|
||||
|
||||
/// Process groups are not exposed on Windows.
|
||||
/// Always returns `None`.
|
||||
pub fn process_group_id(_pid: i32) -> Option<i32> {
|
||||
pub const fn process_group_id(_pid: i32) -> Option<i32> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Process groups are not exposed on Windows.
|
||||
/// Always returns `false`.
|
||||
pub fn kill_process_group(_pgid: i32, _signal: i32) -> bool {
|
||||
pub const fn kill_process_group(_pgid: i32, _signal: i32) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -268,12 +272,14 @@ pub fn kill_tree(pid: i32, signal: i32) -> u32 {
|
||||
|
||||
/// Get the process group id for `pid`.
|
||||
/// Returns `None` when the process is missing or unsupported on the platform.
|
||||
#[allow(clippy::missing_const_for_fn, reason = "Dispatches to platform-specific implementation")]
|
||||
pub fn process_group_id(pid: i32) -> Option<i32> {
|
||||
platform::process_group_id(pid)
|
||||
}
|
||||
|
||||
/// Send `signal` to the process group `pgid`.
|
||||
/// Returns false when process groups are unsupported on the platform.
|
||||
#[allow(clippy::missing_const_for_fn, reason = "Dispatches to platform-specific implementation")]
|
||||
pub fn kill_process_group(pgid: i32, signal: i32) -> bool {
|
||||
platform::kill_process_group(pgid, signal)
|
||||
}
|
||||
|
||||
@@ -347,7 +347,7 @@ const fn exit_code(result: &ExecutionResult) -> i32 {
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn normalize_env_key(key: &str) -> &str {
|
||||
const fn normalize_env_key(key: &str) -> &str {
|
||||
if key.eq_ignore_ascii_case("PATH") {
|
||||
"PATH"
|
||||
} else {
|
||||
@@ -368,8 +368,7 @@ fn merge_path_values(existing: &str, incoming: &str) -> String {
|
||||
push_unique_paths(&mut merged, &mut seen, incoming);
|
||||
|
||||
std::env::join_paths(merged.iter())
|
||||
.map(|paths| paths.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|_| merged.join(";"))
|
||||
.map_or_else(|_| merged.join(";"), |paths| paths.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
@@ -452,10 +451,10 @@ async fn create_session(config: &ShellConfig) -> Result<ShellSessionCore> {
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
if merged_path.is_none() {
|
||||
if let Some(value) = std::env::var_os("Path").or_else(|| std::env::var_os("PATH")) {
|
||||
merged_path = Some(value.to_string_lossy().to_string());
|
||||
}
|
||||
if merged_path.is_none()
|
||||
&& let Some(value) = std::env::var_os("Path").or_else(|| std::env::var_os("PATH"))
|
||||
{
|
||||
merged_path = Some(value.to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
if let Some(path_value) = merged_path {
|
||||
|
||||
@@ -112,12 +112,11 @@ fn query_git_install_path_from_registry() -> Option<String> {
|
||||
let key_paths = ["SOFTWARE\\GitForWindows", "SOFTWARE\\WOW6432Node\\GitForWindows"];
|
||||
|
||||
for key_path in key_paths {
|
||||
if let Ok(key) = hklm.open_subkey(key_path) {
|
||||
if let Ok(path) = key.get_value::<String, _>("InstallPath") {
|
||||
if !path.is_empty() {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
if let Ok(key) = hklm.open_subkey(key_path)
|
||||
&& let Ok(path) = key.get_value::<String, _>("InstallPath")
|
||||
&& !path.is_empty()
|
||||
{
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.78",
|
||||
"@anthropic-ai/sdk": "^0.77.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.998",
|
||||
"@bufbuild/protobuf": "^2.11",
|
||||
"@google/genai": "^1.43",
|
||||
@@ -48,7 +48,7 @@
|
||||
"ajv-formats": "^3.0",
|
||||
"openai": "^6.25",
|
||||
"partial-json": "^0.1",
|
||||
"zod": "^4.3"
|
||||
"zod": "4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3"
|
||||
|
||||
@@ -6,18 +6,21 @@
|
||||
|
||||
- Added LM Studio integration to the model registry and discovery flow.
|
||||
- Added support for authenticating with LM Studio using the `/login lm-studio` command.
|
||||
- Added `fuse-projfs` task isolation mode for Windows ProjFS-backed overlays.
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated Anthropic Foundry environment variable documentation and CLI help text to the canonical names: `CLAUDE_CODE_USE_FOUNDRY`, `CLAUDE_CODE_CLIENT_CERT`, and `CLAUDE_CODE_CLIENT_KEY`
|
||||
- Documented Foundry-specific Anthropic runtime configuration (`FOUNDRY_BASE_URL`, `ANTHROPIC_FOUNDRY_API_KEY`, `ANTHROPIC_CUSTOM_HEADERS`, `NODE_EXTRA_CA_CERTS`) in environment variable reference docs
|
||||
- `fuse-overlay` task isolation now targets `fuse-overlayfs` on Unix hosts only; on Windows it falls back to `worktree` with a `<system-notification>` suggesting `fuse-projfs`.
|
||||
- `fuse-projfs` now performs Windows ProjFS preflight checks and falls back to `worktree` when host or repository prerequisites are unavailable.
|
||||
- Cross-repo patch capture now uses the platform null device (`NUL` on Windows, `/dev/null` elsewhere) for `git diff --no-index`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed MCP resource subscription handling to prevent unsubscribing when notifications are re-enabled after being disabled
|
||||
- Fixed LM Studio base URL validation to preserve invalid configured URLs instead of silently falling back to localhost
|
||||
- Fixed URI template matching to correctly handle expressions that expand to empty strings
|
||||
|
||||
## [13.5.6] - 2026-03-01
|
||||
### Changed
|
||||
|
||||
@@ -1067,7 +1070,6 @@
|
||||
- Improved error reporting in fetch tool to include HTTP status codes when URL fetching fails
|
||||
- Fixed fetch tool to preserve actual response metadata (finalUrl, contentType) instead of defaults when requests fail
|
||||
|
||||
||||||| parent of a70a34c8b (fix(coding-agent/debug): Sanitized debug log rendering)
|
||||
|
||||
## [12.1.0] - 2026-02-13
|
||||
|
||||
|
||||
@@ -906,9 +906,10 @@ Despite the name `runSubprocess`, `packages/coding-agent/src/task/executor.ts` c
|
||||
|
||||
What _is_ isolated is execution context and artifacts, not process memory:
|
||||
|
||||
- Optional filesystem isolation is controlled by the `task.isolation.mode` setting (`"none"`, `"worktree"`, or `"fuse-overlay"`).
|
||||
- Optional filesystem isolation is controlled by the `task.isolation.mode` setting (`"none"`, `"worktree"`, `"fuse-overlay"`, or `"fuse-projfs"`).
|
||||
- **worktree**: `ensureWorktree(...)`, `applyBaseline(...)`, `captureDeltaPatch(...)`, `cleanupWorktree(...)`. Nested non-submodule git repos are discovered and handled independently.
|
||||
- **fuse-overlay**: `ensureFuseOverlay(...)` (mounts a copy-on-write overlay via `fuse-overlayfs`), `captureDeltaPatch(...)`, `cleanupFuseOverlay(...)`. No baseline apply needed since the overlay reflects the full working tree. Fails outright if mount fails.
|
||||
- **fuse-overlay**: `ensureFuseOverlay(...)`, `captureDeltaPatch(...)`, `cleanupFuseOverlay(...)` using `fuse-overlayfs` on Unix hosts. On Windows, this mode falls back to `worktree` with a system notification.
|
||||
- **fuse-projfs**: `ensureProjfsOverlay(...)`, `captureDeltaPatch(...)`, `cleanupProjfsOverlay(...)` using ProjFS on Windows. Missing ProjFS prerequisites fall back to `worktree` with a system notification; non-prerequisite startup errors still fail the task.
|
||||
- The `task.isolation.merge` setting controls how isolated changes are integrated back:
|
||||
- **patch** (default): captures a diff via `captureDeltaPatch(...)`, combines patches, and applies with `git apply`.
|
||||
- **branch**: each task commits to a temp branch (`omp/task/<id>`) via `commitToBranch(...)`, then `mergeTaskBranches(...)` cherry-picks them sequentially onto HEAD. If `git apply` fails inside `commitToBranch`, the error is non-fatal — the agent result is preserved with a `merge failed` status.
|
||||
|
||||
@@ -570,12 +570,13 @@ export const SETTINGS_SCHEMA = {
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
"task.isolation.mode": {
|
||||
type: "enum",
|
||||
values: ["none", "worktree", "fuse-overlay"] as const,
|
||||
values: ["none", "worktree", "fuse-overlay", "fuse-projfs"] as const,
|
||||
default: "none",
|
||||
ui: {
|
||||
tab: "tools",
|
||||
label: "Task isolation",
|
||||
description: "Isolation mode for subagents (none, git worktree, or fuse-overlay)",
|
||||
description:
|
||||
"Isolation mode for subagents (none, git worktree, fuse-overlayfs on Unix, or ProjFS on Windows via fuse-projfs; unsupported modes fall back to worktree)",
|
||||
submenu: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -97,7 +97,16 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
|
||||
"task.isolation.mode": [
|
||||
{ value: "none", label: "None", description: "No isolation" },
|
||||
{ value: "worktree", label: "Worktree", description: "Git worktree isolation" },
|
||||
{ value: "fuse-overlay", label: "Fuse Overlay", description: "COW overlay via fuse-overlayfs" },
|
||||
{
|
||||
value: "fuse-overlay",
|
||||
label: "Fuse Overlay",
|
||||
description: "COW overlay via fuse-overlayfs (Unix only)",
|
||||
},
|
||||
{
|
||||
value: "fuse-projfs",
|
||||
label: "Fuse ProjFS",
|
||||
description: "COW overlay via ProjFS (Windows only; falls back to worktree if unavailable)",
|
||||
},
|
||||
],
|
||||
// Task isolation merge strategy
|
||||
"task.isolation.merge": [
|
||||
|
||||
@@ -32,6 +32,7 @@ import "../tools/review";
|
||||
import { generateCommitMessage } from "../utils/commit-message-generator";
|
||||
import { discoverAgents, getAgent } from "./discovery";
|
||||
import { runSubprocess } from "./executor";
|
||||
import { resolveIsolationBackendForTaskExecution } from "./isolation-backend";
|
||||
import { AgentOutputManager } from "./output-manager";
|
||||
import { mapWithConcurrencyLimit, Semaphore } from "./parallel";
|
||||
import { renderCall, renderResult } from "./render";
|
||||
@@ -52,10 +53,12 @@ import {
|
||||
captureBaseline,
|
||||
captureDeltaPatch,
|
||||
cleanupFuseOverlay,
|
||||
cleanupProjfsOverlay,
|
||||
cleanupTaskBranches,
|
||||
cleanupWorktree,
|
||||
commitToBranch,
|
||||
ensureFuseOverlay,
|
||||
ensureProjfsOverlay,
|
||||
ensureWorktree,
|
||||
getRepoRoot,
|
||||
mergeTaskBranches,
|
||||
@@ -442,7 +445,7 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Task isolation is disabled. Remove the isolated argument or set task.isolation.mode to 'worktree' or 'fuse-overlay'.",
|
||||
text: "Task isolation is disabled. Remove the isolated argument or set task.isolation.mode to 'worktree', 'fuse-overlay', or 'fuse-projfs'.",
|
||||
},
|
||||
],
|
||||
details: {
|
||||
@@ -605,6 +608,29 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
|
||||
}
|
||||
}
|
||||
|
||||
let effectiveIsolationMode = isolationMode;
|
||||
let isolationBackendWarning = "";
|
||||
try {
|
||||
const resolvedIsolation = await resolveIsolationBackendForTaskExecution(isolationMode, isIsolated, repoRoot);
|
||||
effectiveIsolationMode = resolvedIsolation.effectiveIsolationMode;
|
||||
isolationBackendWarning = resolvedIsolation.warning;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: message,
|
||||
},
|
||||
],
|
||||
details: {
|
||||
projectAgentsDir,
|
||||
results: [],
|
||||
totalDurationMs: Date.now() - startTime,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Derive artifacts directory
|
||||
const sessionFile = this.session.getSessionFile();
|
||||
const artifactsDir = sessionFile ? sessionFile.slice(0, -6) : null;
|
||||
@@ -761,8 +787,10 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
|
||||
}
|
||||
const taskBaseline = structuredClone(baseline);
|
||||
|
||||
if (isolationMode === "fuse-overlay") {
|
||||
if (effectiveIsolationMode === "fuse-overlay") {
|
||||
isolationDir = await ensureFuseOverlay(repoRoot, task.id);
|
||||
} else if (effectiveIsolationMode === "fuse-projfs") {
|
||||
isolationDir = await ensureProjfsOverlay(repoRoot, task.id);
|
||||
} else {
|
||||
isolationDir = await ensureWorktree(repoRoot, task.id);
|
||||
await applyBaseline(isolationDir, taskBaseline);
|
||||
@@ -871,8 +899,10 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
|
||||
};
|
||||
} finally {
|
||||
if (isolationDir) {
|
||||
if (isolationMode === "fuse-overlay") {
|
||||
if (effectiveIsolationMode === "fuse-overlay") {
|
||||
await cleanupFuseOverlay(isolationDir);
|
||||
} else if (effectiveIsolationMode === "fuse-projfs") {
|
||||
await cleanupProjfsOverlay(isolationDir);
|
||||
} else {
|
||||
await cleanupWorktree(isolationDir);
|
||||
}
|
||||
@@ -1108,6 +1138,7 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
|
||||
});
|
||||
|
||||
const outputIds = results.filter(r => !r.aborted || r.output.trim()).map(r => `agent://${r.id}`);
|
||||
const backendSummaryPrefix = isolationBackendWarning ? `\n\n${isolationBackendWarning}` : "";
|
||||
const summary = renderPromptTemplate(taskSummaryTemplate, {
|
||||
successCount,
|
||||
totalCount: results.length,
|
||||
@@ -1117,7 +1148,7 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
|
||||
summaries,
|
||||
outputIds,
|
||||
agentName,
|
||||
mergeSummary,
|
||||
mergeSummary: `${backendSummaryPrefix}${mergeSummary}`,
|
||||
});
|
||||
|
||||
// Cleanup temp directory if used
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { projfsOverlayProbe } from "@oh-my-pi/pi-natives";
|
||||
import { Snowflake } from "@oh-my-pi/pi-utils";
|
||||
import { cleanupProjfsOverlay, ensureProjfsOverlay, isProjfsUnavailableError } from "./worktree";
|
||||
|
||||
export type TaskIsolationMode = "none" | "worktree" | "fuse-overlay" | "fuse-projfs";
|
||||
|
||||
export interface IsolationBackendResolution {
|
||||
effectiveIsolationMode: TaskIsolationMode;
|
||||
warning: string;
|
||||
}
|
||||
|
||||
export async function resolveIsolationBackendForTaskExecution(
|
||||
requestedMode: TaskIsolationMode,
|
||||
isIsolated: boolean,
|
||||
repoRoot: string | null,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Promise<IsolationBackendResolution> {
|
||||
let effectiveIsolationMode = requestedMode;
|
||||
let warning = "";
|
||||
if (!(isIsolated && repoRoot)) {
|
||||
return { effectiveIsolationMode, warning };
|
||||
}
|
||||
|
||||
if (requestedMode === "fuse-overlay" && platform === "win32") {
|
||||
effectiveIsolationMode = "worktree";
|
||||
warning =
|
||||
'<system-notification>fuse-overlay isolation is unavailable on Windows. Use task.isolation.mode = "fuse-projfs" for ProjFS. Falling back to worktree isolation.</system-notification>';
|
||||
return { effectiveIsolationMode, warning };
|
||||
}
|
||||
|
||||
if (requestedMode === "fuse-projfs" && platform !== "win32") {
|
||||
effectiveIsolationMode = "worktree";
|
||||
warning =
|
||||
"<system-notification>fuse-projfs isolation is only available on Windows. Falling back to worktree isolation.</system-notification>";
|
||||
return { effectiveIsolationMode, warning };
|
||||
}
|
||||
|
||||
if (!(requestedMode === "fuse-projfs" && platform === "win32")) {
|
||||
return { effectiveIsolationMode, warning };
|
||||
}
|
||||
|
||||
const probe = projfsOverlayProbe();
|
||||
if (!probe.available) {
|
||||
effectiveIsolationMode = "worktree";
|
||||
const reason = probe.reason ? ` Reason: ${probe.reason}` : "";
|
||||
warning = `<system-notification>ProjFS is unavailable on this host. Falling back to worktree isolation.${reason}</system-notification>`;
|
||||
return { effectiveIsolationMode, warning };
|
||||
}
|
||||
|
||||
const probeIsolationId = `probe-${Snowflake.next()}`;
|
||||
let probeIsolationDir: string | null = null;
|
||||
try {
|
||||
probeIsolationDir = await ensureProjfsOverlay(repoRoot, probeIsolationId);
|
||||
} catch (err) {
|
||||
if (isProjfsUnavailableError(err)) {
|
||||
effectiveIsolationMode = "worktree";
|
||||
const raw = err instanceof Error ? err.message : String(err);
|
||||
const reason = raw.replace(/^PROJFS_UNAVAILABLE:\s*/, "");
|
||||
const detail = reason ? ` Reason: ${reason}` : "";
|
||||
warning = `<system-notification>ProjFS prerequisites are unavailable for this repository. Falling back to worktree isolation.${detail}</system-notification>`;
|
||||
} else {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`ProjFS isolation initialization failed. ${message}`);
|
||||
}
|
||||
} finally {
|
||||
if (probeIsolationDir) {
|
||||
await cleanupProjfsOverlay(probeIsolationDir);
|
||||
}
|
||||
}
|
||||
|
||||
return { effectiveIsolationMode, warning };
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { Dirent } from "node:fs";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import path from "node:path";
|
||||
import { projfsOverlayStart, projfsOverlayStop } from "@oh-my-pi/pi-natives";
|
||||
import { getWorktreeDir, isEnoent, logger, Snowflake } from "@oh-my-pi/pi-utils";
|
||||
import { $ } from "bun";
|
||||
|
||||
@@ -37,6 +38,17 @@ export async function getRepoRoot(cwd: string): Promise<string> {
|
||||
return repoRoot;
|
||||
}
|
||||
|
||||
const PROJFS_UNAVAILABLE_PREFIX = "PROJFS_UNAVAILABLE:";
|
||||
const GIT_NO_INDEX_NULL_PATH = process.platform === "win32" ? "NUL" : "/dev/null";
|
||||
|
||||
export function isProjfsUnavailableError(err: unknown): boolean {
|
||||
return err instanceof Error && err.message.includes(PROJFS_UNAVAILABLE_PREFIX);
|
||||
}
|
||||
|
||||
export function getGitNoIndexNullPath(): string {
|
||||
return GIT_NO_INDEX_NULL_PATH;
|
||||
}
|
||||
|
||||
export async function ensureWorktree(baseCwd: string, id: string): Promise<string> {
|
||||
const repoRoot = await getRepoRoot(baseCwd);
|
||||
const encodedProject = getEncodedProjectName(repoRoot);
|
||||
@@ -248,9 +260,10 @@ async function captureRepoDeltaPatch(repoDir: string, rb: RepoBaseline): Promise
|
||||
const baselineUntracked = new Set(rb.untracked);
|
||||
const newUntracked = currentUntracked.filter(entry => !baselineUntracked.has(entry));
|
||||
if (newUntracked.length > 0) {
|
||||
const nullPath = getGitNoIndexNullPath();
|
||||
const untrackedDiffs = await Promise.all(
|
||||
newUntracked.map(entry =>
|
||||
$`git diff --binary --no-index /dev/null ${entry}`.cwd(repoDir).quiet().nothrow().text(),
|
||||
$`git diff --binary --no-index ${nullPath} ${entry}`.cwd(repoDir).quiet().nothrow().text(),
|
||||
),
|
||||
);
|
||||
parts.push(...untrackedDiffs.filter(d => d.trim()));
|
||||
@@ -273,9 +286,10 @@ async function captureRepoDeltaPatch(repoDir: string, rb: RepoBaseline): Promise
|
||||
|
||||
if (newUntracked.length === 0) return diff;
|
||||
|
||||
const nullPath = getGitNoIndexNullPath();
|
||||
const untrackedDiffs = await Promise.all(
|
||||
newUntracked.map(entry =>
|
||||
$`git diff --binary --no-index /dev/null ${entry}`.cwd(repoDir).quiet().nothrow().text(),
|
||||
$`git diff --binary --no-index ${nullPath} ${entry}`.cwd(repoDir).quiet().nothrow().text(),
|
||||
),
|
||||
);
|
||||
return `${diff}${diff && !diff.endsWith("\n") ? "\n" : ""}${untrackedDiffs.join("\n")}`;
|
||||
@@ -371,10 +385,14 @@ export async function cleanupWorktree(dir: string): Promise<void> {
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Fuse-overlay isolation
|
||||
// Fuse-overlay isolation (Unix)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export async function ensureFuseOverlay(baseCwd: string, id: string): Promise<string> {
|
||||
if (process.platform === "win32") {
|
||||
throw new Error('fuse-overlay isolation is unsupported on Windows. Use task.isolation.mode = "fuse-projfs".');
|
||||
}
|
||||
|
||||
const repoRoot = await getRepoRoot(baseCwd);
|
||||
const encodedProject = getEncodedProjectName(repoRoot);
|
||||
const baseDir = getWorktreeDir(encodedProject, id);
|
||||
@@ -382,13 +400,13 @@ export async function ensureFuseOverlay(baseCwd: string, id: string): Promise<st
|
||||
const workDir = path.join(baseDir, "work");
|
||||
const mergedDir = path.join(baseDir, "merged");
|
||||
|
||||
// Clean up any stale mount at this path
|
||||
// Clean up any stale mount at this path (linux only)
|
||||
const fusermount = Bun.which("fusermount3") ?? Bun.which("fusermount");
|
||||
if (fusermount) {
|
||||
await $`${fusermount} -u ${mergedDir}`.quiet().nothrow();
|
||||
}
|
||||
await fs.rm(baseDir, { recursive: true, force: true });
|
||||
|
||||
await fs.rm(baseDir, { recursive: true, force: true });
|
||||
await fs.mkdir(upperDir, { recursive: true });
|
||||
await fs.mkdir(workDir, { recursive: true });
|
||||
await fs.mkdir(mergedDir, { recursive: true });
|
||||
@@ -426,6 +444,50 @@ export async function cleanupFuseOverlay(mergedDir: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// ProjFS isolation (Windows)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export async function ensureProjfsOverlay(baseCwd: string, id: string): Promise<string> {
|
||||
if (process.platform !== "win32") {
|
||||
throw new Error("fuse-projfs isolation is only available on Windows.");
|
||||
}
|
||||
|
||||
const repoRoot = await getRepoRoot(baseCwd);
|
||||
const encodedProject = getEncodedProjectName(repoRoot);
|
||||
const baseDir = getWorktreeDir(encodedProject, id);
|
||||
const mergedDir = path.join(baseDir, "merged");
|
||||
|
||||
await fs.rm(baseDir, { recursive: true, force: true });
|
||||
await fs.mkdir(mergedDir, { recursive: true });
|
||||
try {
|
||||
projfsOverlayStart(repoRoot, mergedDir);
|
||||
return mergedDir;
|
||||
} catch (err) {
|
||||
await fs.rm(baseDir, { recursive: true, force: true });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanupProjfsOverlay(mergedDir: string): Promise<void> {
|
||||
try {
|
||||
if (process.platform === "win32") {
|
||||
try {
|
||||
projfsOverlayStop(mergedDir);
|
||||
} catch (err) {
|
||||
logger.warn("ProjFS overlay stop failed during cleanup", {
|
||||
mergedDir,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// baseDir is the parent of the merged directory
|
||||
const baseDir = path.dirname(mergedDir);
|
||||
await fs.rm(baseDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Branch-mode isolation
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { afterEach, describe, expect, it, vi } from "bun:test";
|
||||
|
||||
const projfsOverlayProbeMock = vi.fn();
|
||||
const ensureProjfsOverlayMock = vi.fn();
|
||||
const cleanupProjfsOverlayMock = vi.fn();
|
||||
const isProjfsUnavailableErrorMock = vi.fn(
|
||||
(err: unknown) => err instanceof Error && err.message.includes("PROJFS_UNAVAILABLE:"),
|
||||
);
|
||||
|
||||
vi.mock("@oh-my-pi/pi-natives", () => ({
|
||||
projfsOverlayProbe: projfsOverlayProbeMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../src/task/worktree", () => ({
|
||||
ensureProjfsOverlay: ensureProjfsOverlayMock,
|
||||
cleanupProjfsOverlay: cleanupProjfsOverlayMock,
|
||||
isProjfsUnavailableError: isProjfsUnavailableErrorMock,
|
||||
getGitNoIndexNullPath: () => (process.platform === "win32" ? "NUL" : "/dev/null"),
|
||||
}));
|
||||
|
||||
async function loadIsolatedBackend() {
|
||||
const { resolveIsolationBackendForTaskExecution } = await import("../../src/task/isolation-backend");
|
||||
return {
|
||||
resolveIsolationBackendForTaskExecution,
|
||||
projfsOverlayProbeMock,
|
||||
ensureProjfsOverlayMock,
|
||||
cleanupProjfsOverlayMock,
|
||||
isProjfsUnavailableErrorMock,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveIsolationBackendForTaskExecution", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
isProjfsUnavailableErrorMock.mockImplementation(
|
||||
(err: unknown) => err instanceof Error && err.message.includes("PROJFS_UNAVAILABLE:"),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to worktree when fuse-overlay is requested on Windows", async () => {
|
||||
const backend = await loadIsolatedBackend();
|
||||
|
||||
const resolved = await backend.resolveIsolationBackendForTaskExecution("fuse-overlay", true, "C:/repo", "win32");
|
||||
|
||||
expect(resolved.effectiveIsolationMode).toBe("worktree");
|
||||
expect(resolved.warning).toContain("fuse-projfs");
|
||||
expect(backend.projfsOverlayProbeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to worktree when fuse-projfs is requested on non-Windows", async () => {
|
||||
const backend = await loadIsolatedBackend();
|
||||
|
||||
const resolved = await backend.resolveIsolationBackendForTaskExecution("fuse-projfs", true, "/repo", "linux");
|
||||
|
||||
expect(resolved.effectiveIsolationMode).toBe("worktree");
|
||||
expect(resolved.warning).toContain("only available on Windows");
|
||||
expect(backend.projfsOverlayProbeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to worktree when ProjFS probe is unavailable on Windows", async () => {
|
||||
const backend = await loadIsolatedBackend();
|
||||
backend.projfsOverlayProbeMock.mockReturnValue({
|
||||
available: false,
|
||||
reason: "Client-ProjFS optional feature disabled",
|
||||
});
|
||||
|
||||
const resolved = await backend.resolveIsolationBackendForTaskExecution("fuse-projfs", true, "C:/repo", "win32");
|
||||
|
||||
expect(resolved.effectiveIsolationMode).toBe("worktree");
|
||||
expect(resolved.warning).toContain("Falling back to worktree isolation");
|
||||
expect(backend.ensureProjfsOverlayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to worktree when ProjFS preflight returns prerequisite error", async () => {
|
||||
const backend = await loadIsolatedBackend();
|
||||
backend.projfsOverlayProbeMock.mockReturnValue({ available: true });
|
||||
backend.ensureProjfsOverlayMock.mockRejectedValue(
|
||||
new Error("PROJFS_UNAVAILABLE: filesystem does not support ProjFS"),
|
||||
);
|
||||
backend.isProjfsUnavailableErrorMock.mockReturnValue(true);
|
||||
|
||||
const resolved = await backend.resolveIsolationBackendForTaskExecution("fuse-projfs", true, "C:/repo", "win32");
|
||||
|
||||
expect(resolved.effectiveIsolationMode).toBe("worktree");
|
||||
expect(resolved.warning).toContain("filesystem does not support ProjFS");
|
||||
expect(backend.cleanupProjfsOverlayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps fuse-projfs backend when ProjFS preflight succeeds", async () => {
|
||||
const backend = await loadIsolatedBackend();
|
||||
backend.projfsOverlayProbeMock.mockReturnValue({ available: true });
|
||||
backend.ensureProjfsOverlayMock.mockResolvedValue("C:/repo/.tmp/merged");
|
||||
backend.isProjfsUnavailableErrorMock.mockReturnValue(false);
|
||||
|
||||
const resolved = await backend.resolveIsolationBackendForTaskExecution("fuse-projfs", true, "C:/repo", "win32");
|
||||
|
||||
expect(resolved.effectiveIsolationMode).toBe("fuse-projfs");
|
||||
expect(resolved.warning).toBe("");
|
||||
expect(backend.cleanupProjfsOverlayMock).toHaveBeenCalledWith("C:/repo/.tmp/merged");
|
||||
});
|
||||
|
||||
it("throws when ProjFS preflight fails for non-prerequisite reasons", async () => {
|
||||
const backend = await loadIsolatedBackend();
|
||||
backend.projfsOverlayProbeMock.mockReturnValue({ available: true });
|
||||
backend.ensureProjfsOverlayMock.mockRejectedValue(new Error("unexpected mount failure"));
|
||||
backend.isProjfsUnavailableErrorMock.mockReturnValue(false);
|
||||
|
||||
await expect(
|
||||
backend.resolveIsolationBackendForTaskExecution("fuse-projfs", true, "C:/repo", "win32"),
|
||||
).rejects.toThrow("ProjFS isolation initialization failed. unexpected mount failure");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it, vi } from "bun:test";
|
||||
|
||||
const projfsOverlayStartMock = vi.fn();
|
||||
const projfsOverlayStopMock = vi.fn();
|
||||
|
||||
vi.mock("@oh-my-pi/pi-natives", () => ({
|
||||
projfsOverlayStart: projfsOverlayStartMock,
|
||||
projfsOverlayStop: projfsOverlayStopMock,
|
||||
}));
|
||||
|
||||
async function loadWorktreeHelpers() {
|
||||
const { getGitNoIndexNullPath, isProjfsUnavailableError } = await import("../../src/task/worktree");
|
||||
return { getGitNoIndexNullPath, isProjfsUnavailableError };
|
||||
}
|
||||
|
||||
describe("worktree isolation helpers", () => {
|
||||
it("returns platform-specific null path for git --no-index diffs", async () => {
|
||||
const { getGitNoIndexNullPath } = await loadWorktreeHelpers();
|
||||
const expected = process.platform === "win32" ? "NUL" : "/dev/null";
|
||||
expect(getGitNoIndexNullPath()).toBe(expected);
|
||||
});
|
||||
|
||||
it("detects ProjFS prerequisite errors by prefix", async () => {
|
||||
const { isProjfsUnavailableError } = await loadWorktreeHelpers();
|
||||
expect(isProjfsUnavailableError(new Error("PROJFS_UNAVAILABLE: missing feature"))).toBe(true);
|
||||
expect(isProjfsUnavailableError(new Error("fuse-overlay mount failed"))).toBe(false);
|
||||
expect(isProjfsUnavailableError("PROJFS_UNAVAILABLE: not-an-error-instance")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ export * from "./highlight";
|
||||
export * from "./html";
|
||||
export * from "./image";
|
||||
export * from "./keys";
|
||||
export * from "./projfs";
|
||||
export * from "./ps";
|
||||
export * from "./pty";
|
||||
export * from "./shell";
|
||||
|
||||
@@ -21,6 +21,7 @@ import "./html/types";
|
||||
import "./image/types";
|
||||
import "./keys/types";
|
||||
import "./ps/types";
|
||||
import "./projfs/types";
|
||||
import "./pty/types";
|
||||
import "./shell/types";
|
||||
import "./text/types";
|
||||
@@ -274,6 +275,9 @@ function validateNative(bindings: NativeBindings, source: string): void {
|
||||
checkFn("astEdit");
|
||||
checkFn("detectMacOSAppearance");
|
||||
checkFn("MacAppearanceObserver");
|
||||
checkFn("projfsOverlayProbe");
|
||||
checkFn("projfsOverlayStart");
|
||||
checkFn("projfsOverlayStop");
|
||||
if (missing.length) {
|
||||
throw new Error(
|
||||
`Native addon missing exports (${source}). Missing: ${missing.join(", ")}. ` +
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Windows ProjFS-backed overlay lifecycle bindings.
|
||||
*/
|
||||
|
||||
import { native } from "../native";
|
||||
|
||||
export type { ProjfsOverlayProbeResult } from "./types";
|
||||
export const { projfsOverlayProbe, projfsOverlayStart, projfsOverlayStop } = native;
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Types for Windows ProjFS-backed overlay lifecycle.
|
||||
*/
|
||||
|
||||
/** Result for probing whether ProjFS can be used on this machine. */
|
||||
export interface ProjfsOverlayProbeResult {
|
||||
available: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
declare module "../bindings" {
|
||||
interface NativeBindings {
|
||||
/**
|
||||
* Probe whether ProjFS APIs are available and loadable on the current machine.
|
||||
*/
|
||||
projfsOverlayProbe(): ProjfsOverlayProbeResult;
|
||||
|
||||
/**
|
||||
* Start a ProjFS-backed projection at `projectionRoot` serving files from `lowerRoot`.
|
||||
*/
|
||||
projfsOverlayStart(lowerRoot: string, projectionRoot: string): void;
|
||||
|
||||
/**
|
||||
* Stop a ProjFS-backed projection previously started at `projectionRoot`.
|
||||
*/
|
||||
projfsOverlayStop(projectionRoot: string): void;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user