diff --git a/etc/spack/defaults/windows/config.yaml b/etc/spack/defaults/windows/config.yaml index af50575a3c1a60..b901de7024484a 100644 --- a/etc/spack/defaults/windows/config.yaml +++ b/etc/spack/defaults/windows/config.yaml @@ -1,5 +1,5 @@ config: - locks: false + locks: true build_stage:: - '$user_cache_path/stage' stage_name: '{name}-{version}-{hash:7}' diff --git a/lib/spack/spack/llnl/util/lock.py b/lib/spack/spack/llnl/util/lock.py index dfecfbbf5f6613..bba0834c082e14 100644 --- a/lib/spack/spack/llnl/util/lock.py +++ b/lib/spack/spack/llnl/util/lock.py @@ -2,21 +2,27 @@ # # SPDX-License-Identifier: (Apache-2.0 OR MIT) +import contextlib import errno import os import socket -import sys import time from datetime import datetime +from sys import platform as _platform from types import TracebackType -from typing import IO, Callable, Dict, Generator, Optional, Tuple, Type +from typing import IO, Callable, Dict, Generator, Optional, Tuple, Type # novm from spack.llnl.util import lang, tty from ..string import plural -if sys.platform != "win32": +IS_WINDOWS = _platform == "win32" +if not IS_WINDOWS: import fcntl +else: + import pywintypes + import win32con + import win32file __all__ = [ @@ -33,6 +39,8 @@ "CantCreateLockError", ] +WHOLE_FILE_RANGE = 0xFFFFFFFF if IS_WINDOWS else 0 + ExitFnType = Callable[ [Optional[Type[BaseException]], Optional[BaseException], Optional[TracebackType]], @@ -150,9 +158,122 @@ def _attempts_str(wait_time, nattempts): return " after {} and {}".format(lang.pretty_seconds(wait_time), attempts) +def _low_high(value): + low = value & 0xFFFFFFFF + high = (value >> 32) & 0xFFFFFFFF + return low, high + + +def _setup_overlapped(offset): + overlapped = pywintypes.OVERLAPPED() + # hEvent needs to be null per lockfileex docs + overlapped.hEvent = 0 + offset_low, offset_high = _low_high(offset) + overlapped.Offset = offset_low + overlapped.OffsetHigh = offset_high + return overlapped + + +@contextlib.contextmanager +def _safe_exclusion(lock: "Lock", timeout: Optional[float] = None): + """Lock upgrade guard for Windows, designed to allow for lock upgrading + which Windows file locks do not natively support. + Uses one additional lockfile as a "gate" for upgrades. + + If a process is attempting to upgrade from a read to a write + it must first take a write lock on this intermediate file + before releasing existing read lock and retaking a lock on the same + file but exclusively. + Processes taking write locks in any context must first take a write lock + on this gate to ensure they respect the upgrade of a read to a write + and cannot take a write on the primary lock while the upgrade takes a write + on the gate. + This gate file prevents any other process/lock from catching + the lock with a competing write during the release part of the upgrade. + + Note: on Windows the effective timeout for a caller is up to 2x ``timeout`` + because the gate acquisition and the primary lock acquisition each consume + up to ``timeout`` seconds independently. + + Note: ``.gate_lock`` sidecar files are never deleted; this is a known + limitation. They are harmless empty files and accumulate only up to one + per unique lock path. + """ + if not IS_WINDOWS: + yield + return + timeout = timeout or lock.default_timeout + # Lock used for exclusive lock access is based on lock it's facilitating + # exclusive access to, ensure exclusion locks are as distinct as the locks + # they're gating. Name is .gate_lock i.e. db.lock.gate_lock + gate_lock_path = lock.path + ".gate_lock" + gate_lk = Lock(gate_lock_path, start=lock._start, length=lock._length) + acquired = False + _read_dropped = False + try: + # don't use the acquire lock method to avoid recursion + gate_lk._lock(LockType.WRITE, timeout=timeout) + acquired = True + # lock gate acquired, drop read lock if there is one + # cannot use release_read as we need to drop the OS-level lock, + # not just decrement the nested lock tracker + if lock._reads: + lock._release_lock() + _read_dropped = True + yield + except LockError: + # If the read was actually dropped before the error, restore it. + # Do NOT rely on lock._reads here — it still reflects the pre-drop + # value whether or not the gate was ever acquired. + if _read_dropped: + lock._lock(LockType.READ, timeout=timeout) + raise + finally: + if acquired: + gate_lk._release_lock() + + +@contextlib.contextmanager +def _safe_downgrade(lock: "Lock"): + """Downgrade an exclusive lock to a shared lock. + + On POSIX, fcntl.lockf(LOCK_SH) atomically replaces the exclusive lock with a shared + lock in a single syscall; no _release_lock() is needed afterward. + + On Windows, LockFileEx permits overlapping locks on the same file handle (exclusive + then shared). However, LOCKFILE_FAIL_IMMEDIATELY rejects the overlapping request even + from the same handle, so a blocking LockFileEx call (without LOCKFILE_FAIL_IMMEDIATELY) + is required to stack the shared lock. UnlockFileEx then removes the exclusive lock + first (FIFO order), leaving only the shared lock. The blocking call always returns + immediately here because the same handle already holds the exclusive lock. + """ + yield + assert lock._file_ref is not None, "_safe_downgrade called without an open file handle" + fh = lock._file_ref.fh.fileno() + if IS_WINDOWS: + overlapped = _setup_overlapped(lock._start) + range_low, range_high = _low_high(lock._length) + hfile = win32file._get_osfhandle(fh) + win32file.LockFileEx(hfile, LockType.LOCK_SH, range_low, range_high, overlapped) + lock._release_lock() + else: + fcntl.lockf(fh, LockType.LOCK_SH, lock._length, lock._start, os.SEEK_SET) + + class LockType: READ = 0 WRITE = 1 + if IS_WINDOWS: + LOCK_EX = win32con.LOCKFILE_EXCLUSIVE_LOCK # exclusive lock + LOCK_SH = 0 # shared lock, default + LOCK_NB = win32con.LOCKFILE_FAIL_IMMEDIATELY # non-blocking + LOCK_CATCH = pywintypes.error + else: + LOCK_EX = fcntl.LOCK_EX + LOCK_SH = fcntl.LOCK_SH + LOCK_NB = fcntl.LOCK_NB + LOCK_UN = fcntl.LOCK_UN + LOCK_CATCH = IOError @staticmethod def to_str(tid): @@ -163,9 +284,9 @@ def to_str(tid): @staticmethod def to_module(tid): - lock = fcntl.LOCK_SH + lock = LockType.LOCK_SH if tid == LockType.WRITE: - lock = fcntl.LOCK_EX + lock = LockType.LOCK_EX return lock @staticmethod @@ -191,7 +312,7 @@ def __init__( path: str, *, start: int = 0, - length: int = 0, + length: int = WHOLE_FILE_RANGE, default_timeout: Optional[float] = None, debug: bool = False, desc: str = "", @@ -292,6 +413,16 @@ def _ensure_valid_handle(self) -> IO[bytes]: return self._file_ref.fh + def _lock_fail_condition(self, e): + if IS_WINDOWS: + # 33 "The process cannot access the file because another + # process has locked a portion of the file." + # 32 "The process cannot access the file because it is being + # used by another process" + return e.args[0] not in (32, 33) + else: + return e.errno not in (errno.EAGAIN, errno.EACCES) + @staticmethod def _poll_interval_generator( _wait_times: Optional[Tuple[float, float, float]] = None, @@ -345,6 +476,22 @@ def __setstate__(self, state): self._reads = 0 self._writes = 0 + def __del__(self) -> None: + # On Windows, os.unlink() fails (WinError 32) if any process has the file open, + # even without a byte-range lock held. When a Lock is dropped without an explicit + # release (e.g. a test that raises before cleanup), CPython's reference counting + # calls __del__ immediately, which lets us close the handle before the filesystem + # tries to delete the file. On POSIX the tracker intentionally keeps handles open + # (see OpenFileTracker); guard so we never change POSIX behaviour. + if not IS_WINDOWS or self._file_ref is None: + return + try: + FILE_TRACKER.release(self._file_ref) + except Exception: + pass + self._file_ref = None + self._cached_key = None + def _lock(self, op: int, timeout: Optional[float] = None) -> Tuple[float, int]: """This takes a lock using POSIX locks (``fcntl.lockf``). @@ -361,7 +508,7 @@ def _lock(self, op: int, timeout: Optional[float] = None) -> Tuple[float, int]: fh = self._ensure_valid_handle() - if LockType.to_module(op) == fcntl.LOCK_EX and fh.mode == "rb": + if LockType.to_module(op) == LockType.LOCK_EX and fh.mode == "rb": # Attempt to upgrade to write lock w/a read-only file. # If the file were writable, we'd have opened it rb+ raise LockROFileError(self.path) @@ -394,9 +541,24 @@ def _poll_lock(self, op: int) -> bool: assert self._file_ref is not None, "cannot poll a lock without the file being set" fh = self._file_ref.fh.fileno() module_op = LockType.to_module(op) + try: - # Try to get the lock (will raise if not available.) - fcntl.lockf(fh, module_op | fcntl.LOCK_NB, self._length, self._start, os.SEEK_SET) + if IS_WINDOWS: + overlapped = _setup_overlapped(self._start) + range_low, range_high = _low_high(self._length) + hfile = win32file._get_osfhandle(fh) + win32file.LockFileEx( + hfile, + module_op | LockType.LOCK_NB, # flags + range_low, + range_high, + overlapped, + ) + else: + # Try to get the lock (will raise if not available.) + fcntl.lockf( + fh, module_op | LockType.LOCK_NB, self._length, self._start, os.SEEK_SET + ) # help for debugging distributed locking if self.debug: @@ -409,14 +571,16 @@ def _poll_lock(self, op: int) -> bool: ) # Exclusive locks write their PID/host - if module_op == fcntl.LOCK_EX: + if op == LockType.WRITE: self._write_log_debug_data() return True - except OSError as e: - # EAGAIN and EACCES == locked by another process (so try again) - if e.errno not in (errno.EAGAIN, errno.EACCES): + except LockType.LOCK_CATCH as e: + # check if lock failure or lock is already held + # lock being held means backoff, other failure reasons + # are valid and we should fail + if self._lock_fail_condition(e): raise return False @@ -424,6 +588,7 @@ def _poll_lock(self, op: int) -> bool: def _read_log_debug_data(self) -> None: """Read PID and host data out of the file if it is there.""" assert self._file_ref is not None, "cannot read debug log without the file being set" + self.old_pid = self.pid self.old_host = self.host @@ -438,12 +603,12 @@ def _read_log_debug_data(self) -> None: def _write_log_debug_data(self) -> None: """Write PID and host data to the file, recording old values.""" assert self._file_ref is not None, "cannot write debug log without the file being set" + self.old_pid = self.pid self.old_host = self.host self.pid = os.getpid() self.host = socket.gethostname() - # write pid, host to disk to sync over FS self._file_ref.fh.seek(0) self._file_ref.fh.write(f"pid={self.pid},host={self.host}".encode("utf-8")) @@ -451,16 +616,43 @@ def _write_log_debug_data(self) -> None: self._file_ref.fh.flush() os.fsync(self._file_ref.fh.fileno()) - def _unlock(self) -> None: - """Releases a lock using POSIX locks (``fcntl.lockf``) + def _release_lock(self): + if IS_WINDOWS: + hfile = win32file._get_osfhandle(self._file_ref.fh.fileno()) + overlapped = _setup_overlapped(self._start) + low_range, high_range = _low_high(self._length) + win32file.UnlockFileEx(hfile, low_range, high_range, overlapped) + else: + fcntl.lockf( + self._file_ref.fh.fileno(), + LockType.LOCK_UN, + self._length, + self._start, + os.SEEK_SET, + ) + + def _unlock(self): + """Release the OS-level lock and reset all lock state. - Releases the lock regardless of mode. Note that read locks may be masquerading as write - locks, but this removes either. + Releases the lock regardless of mode. Note that read locks may + be masquerading as write locks, but this removes either. + + Reset all lock attributes to initial states """ assert self._file_ref is not None, "cannot unlock without the file being set" - fcntl.lockf( - self._file_ref.fh.fileno(), fcntl.LOCK_UN, self._length, self._start, os.SEEK_SET - ) + + self._release_lock() + + # On Windows, os.unlink() fails (WinError 32) if any process has the file open. + # Release our file-tracker reference here so the handle closes and the file + # can be deleted. On POSIX the handle is kept alive for performance (the next + # lock operation skips reopening it) and because closing it would drop all + # fcntl locks this process holds on the inode — neither concern applies on Windows. + if IS_WINDOWS: + FILE_TRACKER.release(self._file_ref) + self._file_ref = None + self._cached_key = None + self._reads = 0 self._writes = 0 @@ -500,17 +692,18 @@ def acquire_write(self, timeout: Optional[float] = None) -> bool: timeout = timeout or self.default_timeout if self._writes == 0: - # can raise LockError. - wait_time, nattempts = self._lock(LockType.WRITE, timeout=timeout) - self._writes += 1 - # Log if acquired, which includes counts when verbose - self._log_acquired("WRITE LOCK", wait_time, nattempts) - - # return True only if we weren't nested in a read lock. - # TODO: we may need to return two values: whether we got - # the write lock, and whether this is acquiring a read OR - # write lock for the first time. Now it returns the latter. - return self._reads == 0 + with _safe_exclusion(self, timeout=timeout): + # can raise LockError. + wait_time, nattempts = self._lock(LockType.WRITE, timeout=timeout) + self._writes += 1 + # Log if acquired, which includes counts when verbose + self._log_acquired("WRITE LOCK", wait_time, nattempts) + + # return True only if we weren't nested in a read lock. + # TODO: we may need to return two values: whether we got + # the write lock, and whether this is acquiring a read OR + # write lock for the first time. Now it returns the latter. + return self._reads == 0 else: # Increment the write count for nested lock tracking self._reaffirm_lock() @@ -521,7 +714,12 @@ def _reaffirm_lock(self) -> None: """Fork-safety: always re-affirm the lock with one non-blocking attempt. In the same process, re-locking an already-held byte range succeeds instantly (POSIX). In a forked child that doesn't own the POSIX lock, the call fails immediately and we raise. Use WRITE - if we hold an exclusive lock so we don't accidentally downgrade it.""" + if we hold an exclusive lock so we don't accidentally downgrade it. + + No-op on Windows (Spawn only) + """ + if IS_WINDOWS: + return if self._writes > 0: op = LockType.WRITE elif self._reads > 0: @@ -553,20 +751,51 @@ def try_acquire_write(self) -> bool: """Non-blocking attempt to acquire an exclusive write lock. Returns True if the lock was acquired, False if it would block. + Handles three cases: no lock held (fresh acquire), read held (upgrade), + or write already held (nested). """ - if self._writes == 0: - fh = self._ensure_valid_handle() - if LockType.to_module(LockType.WRITE) == fcntl.LOCK_EX and fh.mode == "rb": - raise LockROFileError(self.path) + if self._writes > 0: + # Already hold exclusive — just nest + self._reaffirm_lock() + self._writes += 1 + return True + + fh = self._ensure_valid_handle() + if LockType.to_module(LockType.WRITE) == LockType.LOCK_EX and fh.mode == "rb": + raise LockROFileError(self.path) + + if not IS_WINDOWS: + # POSIX: fcntl atomically replaces shared with exclusive, or acquires fresh if not self._poll_lock(LockType.WRITE): return False - self._writes += 1 + self._reads = 0 + self._writes = 1 self._log_acquired("WRITE LOCK", 0, 1) return True - else: - self._reaffirm_lock() - self._writes += 1 - return True + + # Windows: gate coordination prevents races during acquisition and upgrade. + # Both the gate and the primary lock are attempted non-blockingly so that + # this method never spins or sleeps. + gate_lk = Lock(self.path + ".gate_lock", start=self._start, length=self._length) + gate_lk._ensure_valid_handle() + if not gate_lk._poll_lock(LockType.WRITE): + return False + had_read = self._reads > 0 + try: + if had_read: + self._release_lock() # drop shared before acquiring exclusive + if not self._poll_lock(LockType.WRITE): + if had_read: + # Unreachable in practice: holding the gate means no competing + # exclusive writer can exist while we attempt to upgrade. + self._poll_lock(LockType.READ) + return False + finally: + gate_lk._release_lock() + self._reads = 0 + self._writes = 1 + self._log_acquired("WRITE LOCK", 0, 1) + return True def is_write_locked(self) -> bool: """Returns ``True`` if the path is write locked, otherwise, ``False``""" @@ -589,13 +818,17 @@ def downgrade_write_to_read(self, timeout: Optional[float] = None) -> None: """ timeout = timeout or self.default_timeout - if self._writes == 1 and self._reads == 0: + if self._writes == 1: self._log_downgrading() - # can raise LockError. - wait_time, nattempts = self._lock(LockType.READ, timeout=timeout) - self._reads = 1 - self._writes = 0 - self._log_downgraded(wait_time, nattempts) + start_time = time.monotonic() + with _safe_downgrade(self): + # Update state inside the yield body, before the post-yield + # OS-level transition in _safe_downgrade. The transient + # inconsistency (_reads=1 while exclusive is still held) is + # harmless because Lock is not thread-safe. + self._reads = 1 + self._writes = 0 + self._log_downgraded(time.monotonic() - start_time, 1) else: raise LockDowngradeError(self.path) @@ -607,13 +840,14 @@ def upgrade_read_to_write(self, timeout: Optional[float] = None) -> None: """ timeout = timeout or self.default_timeout - if self._reads == 1 and self._writes == 0: + if self._reads >= 1 and self._writes == 0: self._log_upgrading() - # can raise LockError. - wait_time, nattempts = self._lock(LockType.WRITE, timeout=timeout) - self._reads = 0 - self._writes = 1 - self._log_upgraded(wait_time, nattempts) + with _safe_exclusion(self, timeout=timeout): + # can raise LockError. + wait_time, nattempts = self._lock(LockType.WRITE, timeout=timeout) + self._reads = 0 + self._writes = 1 + self._log_upgraded(wait_time, nattempts) else: raise LockUpgradeError(self.path) @@ -676,7 +910,8 @@ def release_write(self, release_fn: ReleaseFnType = None) -> bool: result = release_fn() if self._reads > 0: - self._lock(LockType.READ) + with _safe_downgrade(self): + pass else: self._unlock() # can raise LockError. diff --git a/lib/spack/spack/test/llnl/util/lock.py b/lib/spack/spack/test/llnl/util/lock.py index a7b3f9aa6da651..894ad28dfb7504 100644 --- a/lib/spack/spack/test/llnl/util/lock.py +++ b/lib/spack/spack/test/llnl/util/lock.py @@ -49,8 +49,8 @@ if sys.platform != "win32": import fcntl - -pytestmark = pytest.mark.not_on_windows("does not run on windows") +else: + import win32file # @@ -279,7 +279,7 @@ def wait(self): # Process snippets below can be composed into tests. # class AcquireWrite: - def __init__(self, lock_path, start=0, length=0): + def __init__(self, lock_path, start=0, length=1): self.lock_path = lock_path self.start = start self.length = length @@ -296,7 +296,7 @@ def __call__(self, barrier): class AcquireRead: - def __init__(self, lock_path, start=0, length=0): + def __init__(self, lock_path, start=0, length=1): self.lock_path = lock_path self.start = start self.length = length @@ -313,7 +313,7 @@ def __call__(self, barrier): class TimeoutWrite: - def __init__(self, lock_path, start=0, length=0): + def __init__(self, lock_path, start=0, length=1): self.lock_path = lock_path self.start = start self.length = length @@ -331,7 +331,7 @@ def __call__(self, barrier): class TimeoutRead: - def __init__(self, lock_path, start=0, length=0): + def __init__(self, lock_path, start=0, length=1): self.lock_path = lock_path self.start = start self.length = length @@ -570,6 +570,7 @@ def test_write_lock_timeout_with_multiple_readers_3_2_ranges(lock_path): @pytest.mark.skipif(getuid() == 0, reason="user is root") +@pytest.mark.skipif(sys.platform == "win32", reason="Cannot make readonly dir on Windows") def test_read_lock_on_read_only_lockfile(lock_dir, lock_path): """read-only directory, read-only lockfile.""" touch(lock_path) @@ -597,7 +598,8 @@ def test_read_lock_read_only_dir_writable_lockfile(lock_dir, lock_path): pass -@pytest.mark.skipif(False if sys.platform == "win32" else getuid() == 0, reason="user is root") +# skipping on Windows as spack cannot currently make directories read only +@pytest.mark.skipif(sys.platform == "win32" or getuid() == 0, reason="user is root") def test_read_lock_no_lockfile(lock_dir, lock_path): """read-only directory, no lockfile (so can't create).""" with read_only(lock_dir): @@ -646,7 +648,10 @@ def test_upgrade_read_to_write(private_lock_path): lock.release_read() assert lock._reads == 0 assert lock._writes == 0 - assert not lock._file_ref.fh.closed # recycle the file handle for next lock + # On Windows, _unlock() closes the file handle so the file can be deleted + # (Windows raises WinError 32 on unlink if any process has the file open). + if not lk.IS_WINDOWS: + assert not lock._file_ref.fh.closed # recycle the file handle for next lock def test_release_write_downgrades_to_shared(private_lock_path): @@ -1196,7 +1201,7 @@ def test_attempts_str(): def test_lock_str(): lock = lk.Lock("lockfile") lockstr = str(lock) - assert "lockfile[0:0]" in lockstr + assert f"lockfile[0:{lk.WHOLE_FILE_RANGE}]" in lockstr assert "timeout=None" in lockstr assert "#reads=0, #writes=0" in lockstr @@ -1223,6 +1228,7 @@ def test_downgrade_write_fails(tmp_path: pathlib.Path): lock.release_read() +@pytest.mark.skipif(sys.platform == "win32", reason="fcntl unavailable on Windows") @pytest.mark.parametrize( "err_num,err_msg", [ @@ -1245,10 +1251,37 @@ def _lockf(fd, cmd, len, start, whence): monkeypatch.setattr(fcntl, "lockf", _lockf) if err_num in [errno.EAGAIN, errno.EACCES]: - assert not lock._poll_lock(fcntl.LOCK_EX) + assert not lock._poll_lock(lk.LockType.LOCK_EX) + else: + with pytest.raises(OSError, match=err_msg): + lock._poll_lock(lk.LockType.LOCK_EX) + + monkeypatch.undo() + lock.release_read() + + +@pytest.mark.skipif(sys.platform != "win32", reason="win32file only available on Windows") +@pytest.mark.parametrize( + "err_num,err_msg", [(32, "Fake EACCES error analog"), (33, "Fake EAGAIN error analog")] +) +def test_poll_lock_exception_win(tmp_path: pathlib.Path, monkeypatch, err_num, err_msg): + """Test poll lock exception handling.""" + + def LockFileEx(hfile, int_, int1_, int2_, ol): + raise OSError(err_num, err_msg) + + with working_dir(str(tmp_path)): + lockfile = "lockfile" + lock = lk.Lock(lockfile) + lock.acquire_read() + + monkeypatch.setattr(win32file, "LockFileEx", LockFileEx) + + if err_num in [errno.EAGAIN, errno.EACCES]: + assert not lock._poll_lock(lk.LockType.LOCK_EX) else: with pytest.raises(OSError, match=err_msg): - lock._poll_lock(fcntl.LOCK_EX) + lock._poll_lock(lk.LockType.LOCK_EX) monkeypatch.undo() lock.release_read() @@ -1388,6 +1421,30 @@ def test_try_acquire_write(tmp_path: pathlib.Path): lock.release_read() +def test_try_acquire_write_from_read(tmp_path: pathlib.Path): + """try_acquire_write must properly upgrade an existing read to a write.""" + lock = lk.Lock(str(tmp_path / "lockfile")) + + # Acquire a read, then non-blockingly upgrade to write + lock.acquire_read() + assert lock._reads == 1 + assert lock._writes == 0 + + assert lock.try_acquire_write() is True + assert lock._reads == 0 + assert lock._writes == 1 + + # Nested try_acquire_write while holding write + assert lock.try_acquire_write() is True + assert lock._writes == 2 + + lock.release_write() + assert lock._writes == 1 + lock.release_write() + assert lock._reads == 0 + assert lock._writes == 0 + + def _child_fails_to_acquire_read(_lock: lk.Lock): try: _lock.acquire_read(timeout=1e-9) diff --git a/lib/spack/spack/util/lock.py b/lib/spack/spack/util/lock.py index 39a6bdaeef7351..d058faab2e05bb 100644 --- a/lib/spack/spack/util/lock.py +++ b/lib/spack/spack/util/lock.py @@ -6,7 +6,6 @@ import os import stat -import sys from typing import Optional, Tuple import spack.error @@ -39,7 +38,7 @@ def __init__( desc: str = "", enable: bool = True, ) -> None: - self._enable = sys.platform != "win32" and enable + self._enable = enable super().__init__( path, start=start, diff --git a/share/spack/qa/configuration/windows_locking_config.yaml b/share/spack/qa/configuration/windows_locking_config.yaml new file mode 100644 index 00000000000000..266c7c42df93b0 --- /dev/null +++ b/share/spack/qa/configuration/windows_locking_config.yaml @@ -0,0 +1,8 @@ +config: + locks: true + install_tree: + root: $spack\opt\spack + projections: + all: '${ARCHITECTURE}\${COMPILERNAME}-${COMPILERVER}\${PACKAGE}-${VERSION}-${HASH}' + build_stage: + - ~/.spack/stage