Skip to content

Commit add51e6

Browse files
committed
Thread safety
1 parent 6cbc6f9 commit add51e6

21 files changed

Lines changed: 501 additions & 409 deletions

.github/workflows/pytest.yml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,9 @@ jobs:
3535
- mindeps
3636
- py310
3737
- py314
38-
# include:
39-
# - os: ubuntu-latest
40-
# environment: nogil
38+
include:
39+
- os: ubuntu-latest
40+
environment: nogil
4141

4242
steps:
4343
- name: Checkout
@@ -57,9 +57,9 @@ jobs:
5757
if: matrix.environment != 'smoke'
5858
run: pixi run -e ${{ matrix.environment }} coverage
5959

60-
# - name: Free-threading stress test
61-
# if: matrix.environment == 'nogil'
62-
# run: pixi run -e nogil tests --parallel-threads=4
60+
- name: Free-threading stress test
61+
if: matrix.environment == 'nogil'
62+
run: pixi run -e nogil tests --parallel-threads=4
6363

6464
- name: codecov.io
6565
if: matrix.environment != 'smoke'

doc/index.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,13 @@ API Reference
7373
api/search
7474

7575

76+
Thread safety
77+
-------------
78+
pshell is fully thread safe and compatible with free-threading/noGIL interpreters.
79+
If thread safety is required, it is strongly recommended to use Python 3.14 or later;
80+
pshell does not work around bugs in earlier Python versions.
81+
82+
7683
Credits
7784
-------
7885
pshell was initially developed internally since 2014 as ``landg.bash`` by

doc/whats-new.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ What's New
77

88
v1.5.0 (unreleased)
99
-------------------
10+
- Audited thread safety of the whole codebase
11+
- Fixed threading race conditions in :func:`source` and :func:`symlink`
12+
- Fixed issue where :func:`remove` would fail to delete directories even with
13+
``ignore_readonly=True``
1014
- Added formal support for Python 3.13 and 3.14 (the previous version works fine though)
1115
- Bumped minimum version of psutil from 5.6 to 5.7
1216
- Handle deprecation in psutil 6

pshell/env.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
def source(bash_file: str | Path, *, stderr: IO | None = None) -> None:
1919
"""Emulate the bash command ``source <bash_file>``.
2020
The stdout of the command, if any, will be redirected to stderr.
21-
The acquired variables are injected into ``os.environment`` and are
21+
The acquired variables are injected into ``os.environ`` and are
2222
exposed to any subprocess invoked afterwards.
2323
2424
.. note::
@@ -41,12 +41,25 @@ def source(bash_file: str | Path, *, stderr: IO | None = None) -> None:
4141
"""
4242
log.info("Sourcing environment variables from %s", bash_file)
4343

44-
stdout = check_output(f'source "{bash_file}" 1>&2 && env', stderr=stderr)
44+
# Thread safety: spawn a bash subprocess, make it sample the previous
45+
# environment, run the script and sample again.
46+
delim = "!!!pshell-source-delimiter!!!"
47+
stdout = check_output(
48+
f'env && echo {delim} && source "{bash_file}" 1>&2 && env', stderr=stderr
49+
)
4550

51+
is_prev = True
52+
prev_env = {}
4653
for line in stdout.splitlines():
54+
if line == delim:
55+
is_prev = False
56+
continue
4757
(key, _, value) = line.partition("=")
48-
49-
if key not in ("_", "", "SHLVL") and os.getenv(key) != value:
58+
if key in ("_", "", "SHLVL"):
59+
continue
60+
if is_prev:
61+
prev_env[key] = value
62+
elif prev_env.get(key) != value:
5063
log.debug("Setting environment variable: %s=%s", key, value)
5164
os.environ[key] = value
5265

pshell/file.py

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import errno
77
import os
88
import shutil
9-
import stat
109
import sys
1110
from collections.abc import Callable, Iterator
1211
from contextlib import contextmanager
@@ -87,9 +86,10 @@ def onexc(function: object, path: str, excinfo: object) -> None: # noqa:ARG001
8786
nonlocal has_errors
8887
has_errors = True
8988
try:
90-
# chmod u+w
89+
# chmod u+rwx
90+
# Note that directories need the 'x' bit!
9191
mode = os.stat(path).st_mode
92-
os.chmod(path, mode | stat.S_IWUSR)
92+
os.chmod(path, mode | 0o700)
9393
except OSError: # pragma: nocover
9494
pass
9595

@@ -130,7 +130,13 @@ def onexc(function: object, path: str, excinfo: object) -> None: # noqa:ARG001
130130

131131

132132
def chdir(path: str | Path) -> None:
133-
"""Move the present-working directory (pwd) into the target directory."""
133+
"""Move the present-working directory (pwd) into the target directory.
134+
135+
.. note::
136+
137+
This function changes the pwd for the entire process, and as such it
138+
is thread-unsafe and not context-aware.
139+
"""
134140
path = Path(path)
135141
log.info("chdir %s", path)
136142
os.chdir(resolve_env(path))
@@ -151,6 +157,13 @@ def pushd(path: str | Path) -> Iterator[None]:
151157
pushd mydir
152158
...
153159
popd
160+
161+
162+
.. note::
163+
164+
This function changes the pwd for the entire process, not just the
165+
code inside the with block, and as such it is thread-unsafe and not
166+
context-aware.
154167
"""
155168
path = Path(path)
156169
cwd = os.getcwd()
@@ -332,18 +345,13 @@ def symlink(
332345

333346
log.info("Creating symlink %s => %s", src, dst)
334347

335-
if abspath:
336-
os.symlink(real_src, real_dst)
337-
else:
338-
cwd_backup = os.getcwd()
339-
os.chdir(os.path.realpath(os.path.dirname(real_dst)))
340-
try:
341-
# Generate shortest possible relative path
342-
real_src = os.path.relpath(os.path.realpath(real_src))
343-
real_dst = os.path.relpath(os.path.realpath(real_dst))
344-
os.symlink(real_src, real_dst)
345-
finally:
346-
os.chdir(cwd_backup)
348+
if not abspath:
349+
# Generate shortest possible relative path
350+
real_src = os.path.relpath(
351+
os.path.realpath(real_src),
352+
start=os.path.realpath(os.path.dirname(real_src)),
353+
)
354+
os.symlink(real_src, real_dst)
347355

348356

349357
def exists(path: str | Path) -> bool:

pshell/tests/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
import uuid
23

34
import pytest
45

@@ -13,3 +14,10 @@ class StubError(Exception):
1314

1415
unix_only = pytest.mark.skipif(os.name == "nt", reason="Unix only")
1516
windows_only = pytest.mark.skipif(os.name != "nt", reason="Windows only")
17+
18+
19+
def get_name(suffix: str = "") -> str:
20+
"""A unique name to be used with pytest-run-parallel"""
21+
suffix = f"_{suffix}" if suffix else ""
22+
id_ = str(uuid.uuid4()).replace("-", "")
23+
return f"pshell_tests{suffix}_{id_}"

pshell/tests/conftest.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import os
2+
import sys
13
from pathlib import Path
24

35
import pytest
@@ -13,3 +15,19 @@ def test_open(str_or_path):
1315
sh.open(str_or_path("foo/bar/baz"))
1416
"""
1517
return request.param
18+
19+
20+
@pytest.fixture(autouse=True)
21+
def clear_names():
22+
"""Clear any environment variables created with get_name()"""
23+
yield
24+
for key in list(os.environ.keys()):
25+
if key.startswith("pshell_tests"):
26+
del os.environ[key]
27+
28+
29+
if "pytest_run_parallel" not in sys.modules:
30+
31+
@pytest.fixture
32+
def thread_index():
33+
return 0

pshell/tests/data/sleep20.bat

Lines changed: 0 additions & 1 deletion
This file was deleted.

pshell/tests/data/sleep20.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import time
2+
3+
print("ready", flush=True)
4+
time.sleep(20)

pshell/tests/data/sleep20.sh

Lines changed: 0 additions & 4 deletions
This file was deleted.

0 commit comments

Comments
 (0)