Skip to content

Commit aeab833

Browse files
committed
mount: keep custom CWD on SW reset during session when is mounted and CWD has been modified with command
1 parent 204ea68 commit aeab833

4 files changed

Lines changed: 189 additions & 10 deletions

File tree

README_bench.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,13 @@ Reading files from mounted VFS (PC filesystem accessible on device).
8585
| Read 50 x 2KB files | **0.6s** | 3.2s | **5.6x** |
8686
| Read 1 x 100KB file | **0.3s** | 0.5s | **1.8x** |
8787

88+
### ESP32-S3 - USB-CDC - MacOS
89+
90+
| Test | mpytool | mpremote | Speedup |
91+
|------|---------|----------|---------|
92+
| Read 50 x 2KB files | **1.6s** | 3.9s | **2.3x** |
93+
| Read 1 x 100KB file | **0.6s** | 1.0s | **1.7x** |
94+
8895
### Summary
8996

9097
Batch LISTDIR (1 RTT) vs iterative ilistdir (N+1 RTT) makes significant difference when opening many files. The speedup is more pronounced on faster CPUs (ESP32-C6 160MHz vs RP2040 133MHz).

mpytool/mpy.py

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,8 @@ def __init__(self, conn, log=None, chunk_size=None):
144144
# Multi-mount state
145145
self._mounts = [] # [(mid, mount_point, local_path, handler), ...]
146146
self._next_mid = 0
147+
self._custom_cwd = None # CWD to restore after soft reset
148+
self._custom_syspath = None # sys.path to restore after soft reset
147149
# VfsProtocol always registered - handles stale VFS and all mounts
148150
self._vfs_protocol = _mount.VfsProtocol(
149151
conn, remount_fn=self._do_remount_all, log=log)
@@ -328,6 +330,7 @@ def chdir(self, path):
328330
self.import_module('os')
329331
try:
330332
self._mpy_comm.exec(f"os.chdir('{_escape_path(path)}')")
333+
self._custom_cwd = path
331334
except _mpy_comm.CmdError as err:
332335
raise DirNotFound(path) from err
333336

@@ -347,7 +350,9 @@ def set_sys_path(self, *paths):
347350
*paths: path strings
348351
"""
349352
self.import_module('sys')
350-
self._mpy_comm.exec(f"sys.path = {list(paths)!r}")
353+
new_path = list(paths)
354+
self._mpy_comm.exec(f"sys.path = {new_path!r}")
355+
self._custom_syspath = new_path
351356

352357
def prepend_sys_path(self, *paths):
353358
"""Add paths to beginning of sys.path (removes duplicates)
@@ -357,7 +362,9 @@ def prepend_sys_path(self, *paths):
357362
"""
358363
current = self.get_sys_path()
359364
current = [p for p in current if p not in paths]
360-
self._mpy_comm.exec(f"sys.path = {list(paths) + current!r}")
365+
new_path = list(paths) + current
366+
self._mpy_comm.exec(f"sys.path = {new_path!r}")
367+
self._custom_syspath = new_path
361368

362369
def append_sys_path(self, *paths):
363370
"""Add paths to end of sys.path (removes duplicates)
@@ -367,7 +374,9 @@ def append_sys_path(self, *paths):
367374
"""
368375
current = self.get_sys_path()
369376
current = [p for p in current if p not in paths]
370-
self._mpy_comm.exec(f"sys.path = {current + list(paths)!r}")
377+
new_path = current + list(paths)
378+
self._mpy_comm.exec(f"sys.path = {new_path!r}")
379+
self._custom_syspath = new_path
371380

372381
def remove_from_sys_path(self, *paths):
373382
"""Remove specified paths from sys.path
@@ -378,6 +387,7 @@ def remove_from_sys_path(self, *paths):
378387
current = self.get_sys_path()
379388
new_path = [p for p in current if p not in paths]
380389
self._mpy_comm.exec(f"sys.path = {new_path!r}")
390+
self._custom_syspath = new_path
381391

382392
def hashfile(self, path):
383393
"""Compute SHA256 hash of file
@@ -1576,6 +1586,12 @@ def mount(
15761586

15771587
self._mounts.append((mid, mount_point, local_path, handler))
15781588
self._next_mid += 1
1589+
1590+
# First mount: change CWD to mount point (mpremote compatibility)
1591+
if len(self._mounts) == 1:
1592+
self._mpy_comm.exec(f"os.chdir('{mp_escaped}')", timeout=3)
1593+
self._custom_cwd = mount_point
1594+
15791595
return handler
15801596

15811597
def _do_remount_all(self):
@@ -1595,12 +1611,15 @@ def _do_remount_all(self):
15951611
self._mpy_comm.exec(
15961612
f"_mt_mount('{mp_escaped}',{mid},{self._chunk_size})",
15971613
timeout=5)
1598-
# Restore CWD to first mount point (mpremote compatibility)
1599-
if self._mounts:
1600-
first_mount = self._mounts[0]
1601-
if first_mount[0] is not None: # Not a submount
1602-
mp_escaped = _escape_path(first_mount[1])
1603-
self._mpy_comm.exec(
1604-
f"os.chdir('{mp_escaped}')", timeout=3)
1614+
# Restore CWD
1615+
if self._custom_cwd is not None:
1616+
cwd_escaped = _escape_path(self._custom_cwd)
1617+
self._mpy_comm.exec(f"os.chdir('{cwd_escaped}')", timeout=3)
1618+
1619+
# Restore sys.path
1620+
if self._custom_syspath is not None:
1621+
self._mpy_comm.exec(
1622+
f"import sys; sys.path[:] = {self._custom_syspath!r}", timeout=3)
1623+
16051624
self._mpy_comm.exit_raw_repl()
16061625

tests/test_mount.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1960,5 +1960,103 @@ def test_readline_after_seek(self):
19601960
self.assertEqual(result[4:4+length], b'BBB\n')
19611961

19621962

1963+
class TestMountCwdTracking(unittest.TestCase):
1964+
"""Tests for mount() CWD tracking"""
1965+
1966+
def setUp(self):
1967+
self.mock_conn = Mock()
1968+
self.mock_conn._escape_handlers = {}
1969+
self.mock_conn.register_escape_handler = Mock(
1970+
side_effect=lambda b, h: self.mock_conn._escape_handlers.__setitem__(b, h))
1971+
from mpytool.mpy import Mpy
1972+
self.mpy = Mpy(self.mock_conn)
1973+
self.mpy._mpy_comm = Mock()
1974+
self.mpy._mpy_comm.exec_eval.return_value = 4096 # chunk size
1975+
self.temp_dir = tempfile.mkdtemp()
1976+
1977+
def tearDown(self):
1978+
shutil.rmtree(self.temp_dir)
1979+
1980+
def test_first_mount_saves_cwd(self):
1981+
"""First mount saves mount_point to _custom_cwd"""
1982+
self.mpy.mount(self.temp_dir, '/remote')
1983+
self.assertEqual(self.mpy._custom_cwd, '/remote')
1984+
1985+
def test_second_mount_does_not_change_cwd(self):
1986+
"""Second mount does not overwrite _custom_cwd"""
1987+
temp_dir2 = tempfile.mkdtemp()
1988+
try:
1989+
self.mpy.mount(self.temp_dir, '/remote')
1990+
self.mpy.mount(temp_dir2, '/other')
1991+
# CWD should still be from first mount
1992+
self.assertEqual(self.mpy._custom_cwd, '/remote')
1993+
finally:
1994+
shutil.rmtree(temp_dir2)
1995+
1996+
1997+
class TestRemountRestoreState(unittest.TestCase):
1998+
"""Tests for _do_remount_all() state restore"""
1999+
2000+
def setUp(self):
2001+
self.mock_conn = Mock()
2002+
self.mock_conn._escape_handlers = {}
2003+
self.mock_conn.register_escape_handler = Mock(
2004+
side_effect=lambda b, h: self.mock_conn._escape_handlers.__setitem__(b, h))
2005+
from mpytool.mpy import Mpy
2006+
self.mpy = Mpy(self.mock_conn)
2007+
self.mpy._mpy_comm = Mock()
2008+
self.mpy._mpy_comm.exec_eval.return_value = 4096
2009+
self.temp_dir = tempfile.mkdtemp()
2010+
2011+
def tearDown(self):
2012+
shutil.rmtree(self.temp_dir)
2013+
2014+
def test_remount_restores_cwd(self):
2015+
"""_do_remount_all() restores _custom_cwd"""
2016+
self.mpy.mount(self.temp_dir, '/remote')
2017+
self.mpy._custom_cwd = '/lib' # user changed CWD
2018+
self.mpy._mpy_comm.reset_mock()
2019+
2020+
self.mpy._do_remount_all()
2021+
2022+
# Check that os.chdir('/lib') was called
2023+
exec_calls = [str(c) for c in self.mpy._mpy_comm.exec.call_args_list]
2024+
self.assertTrue(
2025+
any("os.chdir('/lib')" in c for c in exec_calls),
2026+
f"Expected os.chdir('/lib') in {exec_calls}")
2027+
2028+
def test_remount_restores_syspath(self):
2029+
"""_do_remount_all() restores _custom_syspath"""
2030+
self.mpy.mount(self.temp_dir, '/remote')
2031+
self.mpy._custom_syspath = ['/lib', '/app']
2032+
self.mpy._mpy_comm.reset_mock()
2033+
2034+
self.mpy._do_remount_all()
2035+
2036+
# Check that sys.path was restored
2037+
exec_calls = [str(c) for c in self.mpy._mpy_comm.exec.call_args_list]
2038+
self.assertTrue(
2039+
any("sys.path[:] = ['/lib', '/app']" in c for c in exec_calls),
2040+
f"Expected sys.path restore in {exec_calls}")
2041+
2042+
def test_remount_no_restore_if_none(self):
2043+
"""_do_remount_all() skips restore if state is None"""
2044+
self.mpy.mount(self.temp_dir, '/remote')
2045+
self.mpy._custom_cwd = None
2046+
self.mpy._custom_syspath = None
2047+
self.mpy._mpy_comm.reset_mock()
2048+
2049+
self.mpy._do_remount_all()
2050+
2051+
# Check that os.chdir and sys.path were NOT called
2052+
exec_calls = [str(c) for c in self.mpy._mpy_comm.exec.call_args_list]
2053+
self.assertFalse(
2054+
any("os.chdir" in c for c in exec_calls),
2055+
f"os.chdir should not be called when _custom_cwd is None")
2056+
self.assertFalse(
2057+
any("sys.path[:] =" in c for c in exec_calls),
2058+
f"sys.path should not be restored when _custom_syspath is None")
2059+
2060+
19632061
if __name__ == '__main__':
19642062
unittest.main()

tests/test_mpy.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,5 +604,60 @@ def test_try_raw_paste_timeout_zero_fallback_exec(self):
604604
self.assertFalse(self.comm._repl_mode)
605605

606606

607+
class TestCwdAndPathTracking(unittest.TestCase):
608+
"""Tests for CWD and sys.path tracking for soft reset restore"""
609+
610+
def setUp(self):
611+
self.mock_conn = Mock()
612+
self.mock_conn._escape_handlers = {}
613+
self.mock_conn.register_escape_handler = Mock(
614+
side_effect=lambda b, h: self.mock_conn._escape_handlers.__setitem__(b, h))
615+
self.mpy = Mpy(self.mock_conn)
616+
617+
def test_initial_state_none(self):
618+
"""_custom_cwd and _custom_syspath are None initially"""
619+
self.assertIsNone(self.mpy._custom_cwd)
620+
self.assertIsNone(self.mpy._custom_syspath)
621+
622+
def test_chdir_saves_cwd(self):
623+
"""chdir() saves path to _custom_cwd"""
624+
self.mpy._mpy_comm = Mock()
625+
self.mpy._imported = ['os']
626+
self.mpy.chdir('/lib')
627+
self.assertEqual(self.mpy._custom_cwd, '/lib')
628+
self.mpy._mpy_comm.exec.assert_called()
629+
630+
def test_set_sys_path_saves_path(self):
631+
"""set_sys_path() saves to _custom_syspath"""
632+
self.mpy._mpy_comm = Mock()
633+
self.mpy._imported = ['sys']
634+
self.mpy.set_sys_path('/lib', '/app')
635+
self.assertEqual(self.mpy._custom_syspath, ['/lib', '/app'])
636+
637+
def test_prepend_sys_path_saves_path(self):
638+
"""prepend_sys_path() saves combined path to _custom_syspath"""
639+
self.mpy._mpy_comm = Mock()
640+
self.mpy._mpy_comm.exec_eval.return_value = ['/old']
641+
self.mpy._imported = ['sys']
642+
self.mpy.prepend_sys_path('/new')
643+
self.assertEqual(self.mpy._custom_syspath, ['/new', '/old'])
644+
645+
def test_append_sys_path_saves_path(self):
646+
"""append_sys_path() saves combined path to _custom_syspath"""
647+
self.mpy._mpy_comm = Mock()
648+
self.mpy._mpy_comm.exec_eval.return_value = ['/old']
649+
self.mpy._imported = ['sys']
650+
self.mpy.append_sys_path('/new')
651+
self.assertEqual(self.mpy._custom_syspath, ['/old', '/new'])
652+
653+
def test_remove_from_sys_path_saves_path(self):
654+
"""remove_from_sys_path() saves filtered path to _custom_syspath"""
655+
self.mpy._mpy_comm = Mock()
656+
self.mpy._mpy_comm.exec_eval.return_value = ['/a', '/b', '/c']
657+
self.mpy._imported = ['sys']
658+
self.mpy.remove_from_sys_path('/b')
659+
self.assertEqual(self.mpy._custom_syspath, ['/a', '/c'])
660+
661+
607662
if __name__ == "__main__":
608663
unittest.main()

0 commit comments

Comments
 (0)