From 4060a582d61606d75f65ecd4d53b491c5b7fd740 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Wed, 13 Aug 2025 10:18:18 -0700 Subject: [PATCH] Linter --- cista/watching.py | 212 ++++++--- tests/test_watching_directory_rename.py | 587 +++++++++++++----------- 2 files changed, 456 insertions(+), 343 deletions(-) diff --git a/cista/watching.py b/cista/watching.py index b42e82b..6e03872 100644 --- a/cista/watching.py +++ b/cista/watching.py @@ -50,12 +50,16 @@ def treeget(rootmod: list[FileEntry], path: PurePosixPath): begin = None ret = [] iteration_count = 0 - + for i, relpath, entry in treeiter(rootmod): iteration_count += 1 - if iteration_count % 1000 == 0: # Log every 1000 iterations to detect infinite loops - logger.debug(f"DEBUG: treeget iteration {iteration_count}, i={i}, relpath={relpath}, entry.name={entry.name}") - + if ( + iteration_count % 1000 == 0 + ): # Log every 1000 iterations to detect infinite loops + logger.debug( + f"DEBUG: treeget iteration {iteration_count}, i={i}, relpath={relpath}, entry.name={entry.name}" + ) + if begin is None: if relpath == path: logger.debug(f"DEBUG: treeget FOUND path {path} at index {i}") @@ -63,59 +67,77 @@ def treeget(rootmod: list[FileEntry], path: PurePosixPath): ret.append(entry) continue if entry.level <= len(path.parts): - logger.debug(f"DEBUG: treeget BREAK: entry.level={entry.level} <= path.parts_len={len(path.parts)}") + logger.debug( + f"DEBUG: treeget BREAK: entry.level={entry.level} <= path.parts_len={len(path.parts)}" + ) break ret.append(entry) - - logger.debug(f"DEBUG: treeget EXIT: path={path}, begin={begin}, ret_len={len(ret)}, iterations={iteration_count}") + + logger.debug( + f"DEBUG: treeget EXIT: path={path}, begin={begin}, ret_len={len(ret)}, iterations={iteration_count}" + ) return begin, ret def treeinspos(rootmod: list[FileEntry], relpath: PurePosixPath, relfile: int): # Find the first entry greater than the new one # precondition: the new entry doesn't exist - logger.debug(f"DEBUG: treeinspos ENTRY: relpath={relpath}, relfile={relfile}, rootmod_len={len(rootmod)}") - + logger.debug( + f"DEBUG: treeinspos ENTRY: relpath={relpath}, relfile={relfile}, rootmod_len={len(rootmod)}" + ) + isfile = 0 level = 0 i = 0 iteration_count = 0 - + for i, rel, entry in treeiter(rootmod): iteration_count += 1 - + # Detect potential infinite loops in treeinspos if iteration_count % 1000 == 0: - logger.debug(f"DEBUG: treeinspos iteration {iteration_count}, i={i}, rel={rel}, entry.name={entry.name}, level={level}, entry.level={entry.level}") - + logger.debug( + f"DEBUG: treeinspos iteration {iteration_count}, i={i}, rel={rel}, entry.name={entry.name}, level={level}, entry.level={entry.level}" + ) + if iteration_count > 10000: # Emergency brake for infinite loops - logger.error(f"ERROR: treeinspos potential infinite loop! iteration={iteration_count}, relpath={relpath}, i={i}, level={level}") + logger.error( + f"ERROR: treeinspos potential infinite loop! iteration={iteration_count}, relpath={relpath}, i={i}, level={level}" + ) break - + if entry.level > level: # We haven't found item at level, skip subdirectories - logger.debug(f"DEBUG: treeinspos SKIP: entry.level={entry.level} > level={level}") + logger.debug( + f"DEBUG: treeinspos SKIP: entry.level={entry.level} > level={level}" + ) continue if entry.level < level: # We have passed the level, so the new item is the first - logger.debug(f"DEBUG: treeinspos RETURN_EARLY: entry.level={entry.level} < level={level}, returning i={i}") + logger.debug( + f"DEBUG: treeinspos RETURN_EARLY: entry.level={entry.level} < level={level}, returning i={i}" + ) return i if level == 0: # root logger.debug("DEBUG: treeinspos ROOT: incrementing level from 0 to 1") level += 1 continue - + ename = rel.parts[level - 1] name = relpath.parts[level - 1] - logger.debug(f"DEBUG: treeinspos COMPARE: ename='{ename}', name='{name}', level={level}") - + logger.debug( + f"DEBUG: treeinspos COMPARE: ename='{ename}', name='{name}', level={level}" + ) + esort = sortkey(ename) nsort = sortkey(name) # Non-leaf are always folders, only use relfile at leaf isfile = relfile if len(relpath.parts) == level else 0 - logger.debug(f"DEBUG: treeinspos SORT: esort={esort}, nsort={nsort}, isfile={isfile}, entry.isfile={entry.isfile}") - + logger.debug( + f"DEBUG: treeinspos SORT: esort={esort}, nsort={nsort}, isfile={isfile}, entry.isfile={entry.isfile}" + ) + # First compare by isfile, then by sorting order and if that too matches then case sensitive cmp = ( entry.isfile - isfile @@ -123,24 +145,28 @@ def treeinspos(rootmod: list[FileEntry], relpath: PurePosixPath, relfile: int): or (ename > name) - (ename < name) ) logger.debug(f"DEBUG: treeinspos CMP: cmp={cmp}") - + if cmp > 0: logger.debug(f"DEBUG: treeinspos RETURN: cmp > 0, returning i={i}") return i if cmp < 0: logger.debug(f"DEBUG: treeinspos CONTINUE: cmp < 0") continue - + logger.debug(f"DEBUG: treeinspos INCREMENT_LEVEL: level {level} -> {level + 1}") level += 1 if level > len(relpath.parts): - logger.error(f"ERROR: insertpos level overflow: relpath={relpath}, i={i}, entry.name={entry.name}, entry.level={entry.level}, level={level}") + logger.error( + f"ERROR: insertpos level overflow: relpath={relpath}, i={i}, entry.name={entry.name}, entry.level={entry.level}, level={level}" + ) break else: logger.debug(f"DEBUG: treeinspos FOR_ELSE: incrementing i from {i} to {i + 1}") i += 1 - - logger.debug(f"DEBUG: treeinspos EXIT: returning i={i}, iterations={iteration_count}") + + logger.debug( + f"DEBUG: treeinspos EXIT: returning i={i}, iterations={iteration_count}" + ) return i @@ -219,20 +245,26 @@ def update_root(loop): def update_path(rootmod: list[FileEntry], relpath: PurePosixPath, loop): """Called on FS updates, check the filesystem and broadcast any changes.""" - logger.debug(f"DEBUG: update_path ENTRY: path={relpath}, rootmod_len={len(rootmod)}") - + logger.debug( + f"DEBUG: update_path ENTRY: path={relpath}, rootmod_len={len(rootmod)}" + ) + # Add timing for walk operation walk_start = time.perf_counter() new = walk(relpath) walk_end = time.perf_counter() - logger.debug(f"DEBUG: walk({relpath}) took {walk_end - walk_start:.4f}s, returned {len(new)} entries") - + logger.debug( + f"DEBUG: walk({relpath}) took {walk_end - walk_start:.4f}s, returned {len(new)} entries" + ) + # Add timing for treeget operation treeget_start = time.perf_counter() obegin, old = treeget(rootmod, relpath) treeget_end = time.perf_counter() - logger.debug(f"DEBUG: treeget({relpath}) took {treeget_end - treeget_start:.4f}s, obegin={obegin}, old_len={len(old) if old else 0}") - + logger.debug( + f"DEBUG: treeget({relpath}) took {treeget_end - treeget_start:.4f}s, obegin={obegin}, old_len={len(old) if old else 0}" + ) + if old == new: logger.debug( f"Watch: Event without changes needed {relpath}" @@ -241,29 +273,37 @@ def update_path(rootmod: list[FileEntry], relpath: PurePosixPath, loop): ) logger.debug(f"DEBUG: update_path EARLY_EXIT: no changes for {relpath}") return - + # Debug the deletion operation if obegin is not None: - logger.debug(f"DEBUG: DELETING entries from rootmod[{obegin}:{obegin + len(old)}] for path {relpath}") + logger.debug( + f"DEBUG: DELETING entries from rootmod[{obegin}:{obegin + len(old)}] for path {relpath}" + ) del rootmod[obegin : obegin + len(old)] logger.debug(f"DEBUG: DELETED entries, rootmod_len now {len(rootmod)}") - + if new: logger.debug(f"Watch: Update {relpath}" if old else f"Watch: Created {relpath}") - + # Add timing for treeinspos operation - this is where hangs might occur inspos_start = time.perf_counter() i = treeinspos(rootmod, relpath, new[0].isfile) inspos_end = time.perf_counter() - logger.debug(f"DEBUG: treeinspos({relpath}) took {inspos_end - inspos_start:.4f}s, returned index={i}") - - logger.debug(f"DEBUG: INSERTING {len(new)} entries at position {i} for path {relpath}") + logger.debug( + f"DEBUG: treeinspos({relpath}) took {inspos_end - inspos_start:.4f}s, returned index={i}" + ) + + logger.debug( + f"DEBUG: INSERTING {len(new)} entries at position {i} for path {relpath}" + ) rootmod[i:i] = new logger.debug(f"DEBUG: INSERTED entries, rootmod_len now {len(rootmod)}") else: logger.debug(f"Watch: Removed {relpath}") - - logger.debug(f"DEBUG: update_path EXIT: path={relpath}, final_rootmod_len={len(rootmod)}") + + logger.debug( + f"DEBUG: update_path EXIT: path={relpath}, final_rootmod_len={len(rootmod)}" + ) def update_space(loop): @@ -284,38 +324,46 @@ def update_space(loop): def format_update(old, new): logger.debug(f"DEBUG: format_update ENTRY: old_len={len(old)}, new_len={len(new)}") - + # Make keep/del/insert diff until one of the lists ends oidx, nidx = 0, 0 oremain, nremain = set(old), set(new) update = [] keep_count = 0 iteration_count = 0 - + while oidx < len(old) and nidx < len(new): iteration_count += 1 - + # Log every 1000 iterations to detect infinite loops if iteration_count % 1000 == 0: - logger.debug(f"DEBUG: format_update iteration {iteration_count}, oidx={oidx}/{len(old)}, nidx={nidx}/{len(new)}") - + logger.debug( + f"DEBUG: format_update iteration {iteration_count}, oidx={oidx}/{len(old)}, nidx={nidx}/{len(new)}" + ) + # Emergency brake for potential infinite loops if iteration_count > 50000: - logger.error(f"ERROR: format_update potential infinite loop! iteration={iteration_count}, oidx={oidx}, nidx={nidx}") - raise Exception(f"format_update infinite loop detected at iteration {iteration_count}") - + logger.error( + f"ERROR: format_update potential infinite loop! iteration={iteration_count}, oidx={oidx}, nidx={nidx}" + ) + raise Exception( + f"format_update infinite loop detected at iteration {iteration_count}" + ) + modified = False # Matching entries are kept if old[oidx] == new[nidx]: entry = old[oidx] - logger.debug(f"DEBUG: format_update MATCH: entry={entry.name}, oidx={oidx}, nidx={nidx}") + logger.debug( + f"DEBUG: format_update MATCH: entry={entry.name}, oidx={oidx}, nidx={nidx}" + ) oremain.remove(entry) nremain.remove(entry) keep_count += 1 oidx += 1 nidx += 1 continue - + if keep_count > 0: logger.debug(f"DEBUG: format_update KEEP: adding UpdKeep({keep_count})") modified = True @@ -326,12 +374,16 @@ def format_update(old, new): del_count = 0 del_start_oidx = oidx while oidx < len(old) and old[oidx] not in nremain: - logger.debug(f"DEBUG: format_update DELETE: removing old[{oidx}]={old[oidx].name}") + logger.debug( + f"DEBUG: format_update DELETE: removing old[{oidx}]={old[oidx].name}" + ) oremain.remove(old[oidx]) del_count += 1 oidx += 1 if del_count: - logger.debug(f"DEBUG: format_update DEL: adding UpdDel({del_count}), oidx {del_start_oidx}->{oidx}") + logger.debug( + f"DEBUG: format_update DEL: adding UpdDel({del_count}), oidx {del_start_oidx}->{oidx}" + ) update.append(UpdDel(del_count)) continue @@ -340,19 +392,29 @@ def format_update(old, new): ins_start_nidx = nidx while nidx < len(new) and new[nidx] not in oremain: entry = new[nidx] - logger.debug(f"DEBUG: format_update INSERT: adding new[{nidx}]={entry.name}") + logger.debug( + f"DEBUG: format_update INSERT: adding new[{nidx}]={entry.name}" + ) nremain.remove(entry) insert_items.append(entry) nidx += 1 if insert_items: - logger.debug(f"DEBUG: format_update INS: adding UpdIns({len(insert_items)} items), nidx {ins_start_nidx}->{nidx}") + logger.debug( + f"DEBUG: format_update INS: adding UpdIns({len(insert_items)} items), nidx {ins_start_nidx}->{nidx}" + ) modified = True update.append(UpdIns(insert_items)) if not modified: - logger.error(f"ERROR: format_update INFINITE_LOOP: nidx={nidx}, oidx={oidx}, old_len={len(old)}, new_len={len(new)}") - logger.error(f"ERROR: old[oidx]={old[oidx].name if oidx < len(old) else 'OUT_OF_BOUNDS'}") - logger.error(f"ERROR: new[nidx]={new[nidx].name if nidx < len(new) else 'OUT_OF_BOUNDS'}") + logger.error( + f"ERROR: format_update INFINITE_LOOP: nidx={nidx}, oidx={oidx}, old_len={len(old)}, new_len={len(new)}" + ) + logger.error( + f"ERROR: old[oidx]={old[oidx].name if oidx < len(old) else 'OUT_OF_BOUNDS'}" + ) + logger.error( + f"ERROR: new[nidx]={new[nidx].name if nidx < len(new) else 'OUT_OF_BOUNDS'}" + ) raise Exception( f"Infinite loop in diff {nidx=} {oidx=} {len(old)=} {len(new)=}" ) @@ -362,13 +424,19 @@ def format_update(old, new): logger.debug(f"DEBUG: format_update FINAL_KEEP: adding UpdKeep({keep_count})") update.append(UpdKeep(keep_count)) if oremain: - logger.debug(f"DEBUG: format_update FINAL_DEL: adding UpdDel({len(oremain)}) for remaining old items") + logger.debug( + f"DEBUG: format_update FINAL_DEL: adding UpdDel({len(oremain)}) for remaining old items" + ) update.append(UpdDel(len(oremain))) elif nremain: - logger.debug(f"DEBUG: format_update FINAL_INS: adding UpdIns({len(new[nidx:])}) for remaining new items") + logger.debug( + f"DEBUG: format_update FINAL_INS: adding UpdIns({len(new[nidx:])}) for remaining new items" + ) update.append(UpdIns(new[nidx:])) - logger.debug(f"DEBUG: format_update EXIT: generated {len(update)} operations, iterations={iteration_count}") + logger.debug( + f"DEBUG: format_update EXIT: generated {len(update)} operations, iterations={iteration_count}" + ) return msgspec.json.encode({"update": update}).decode() @@ -440,16 +508,24 @@ def watcher_inotify(loop): logger.debug(f"Watch: {interesting=} {event=}") if interesting: # Update modified path - logger.debug(f"DEBUG: inotify PROCESSING: event={event}, path={event[2]}/{event[3]}") + logger.debug( + f"DEBUG: inotify PROCESSING: event={event}, path={event[2]}/{event[3]}" + ) t0 = time.perf_counter() path = PurePosixPath(event[2]) / event[3] try: rel_path = path.relative_to(rootpath) - logger.debug(f"DEBUG: inotify CALLING update_path: rel_path={rel_path}") + logger.debug( + f"DEBUG: inotify CALLING update_path: rel_path={rel_path}" + ) update_path(rootmod, rel_path, loop) - logger.debug(f"DEBUG: inotify update_path COMPLETED: rel_path={rel_path}") + logger.debug( + f"DEBUG: inotify update_path COMPLETED: rel_path={rel_path}" + ) except Exception as e: - logger.error(f"ERROR: inotify update_path FAILED: path={path}, error={e}") + logger.error( + f"ERROR: inotify update_path FAILED: path={path}, error={e}" + ) raise t1 = time.perf_counter() logger.debug(f"Watch: Update {event[3]} took {t1 - t0:.1f}s") @@ -461,7 +537,9 @@ def watcher_inotify(loop): logger.debug("DEBUG: inotify TIMEOUT: breaking due to 0.5s timeout") break if dirty and state.root != rootmod: - logger.debug(f"DEBUG: inotify BATCH_UPDATE: state.root_len={len(state.root)}, rootmod_len={len(rootmod)}") + logger.debug( + f"DEBUG: inotify BATCH_UPDATE: state.root_len={len(state.root)}, rootmod_len={len(rootmod)}" + ) t0 = time.perf_counter() logger.debug("DEBUG: inotify CALLING format_update") update = format_update(state.root, rootmod) diff --git a/tests/test_watching_directory_rename.py b/tests/test_watching_directory_rename.py index 0abb6fe..d297502 100644 --- a/tests/test_watching_directory_rename.py +++ b/tests/test_watching_directory_rename.py @@ -29,15 +29,15 @@ def setup_watcher(temp_dir): original_rootpath = watching.rootpath original_state = watching.state original_quit = watching.quit - + # Setup test environment config.config = config.Config(path=temp_dir, listen=":0") watching.rootpath = temp_dir watching.state = watching.State() watching.quit = threading.Event() - + yield temp_dir - + # Cleanup watching.quit.set() watching.rootpath = original_rootpath @@ -50,96 +50,106 @@ def create_test_structure(base_path: Path): # Create main subdirectory with files subdir = base_path / "test_subdir" subdir.mkdir() - + # Add some files to the subdirectory (subdir / "file1.txt").write_text("content1") (subdir / "file2.txt").write_text("content2") - + # Create a nested subdirectory nested = subdir / "nested" nested.mkdir() (nested / "nested_file.txt").write_text("nested content") - + # Create another top-level directory for reference other_dir = base_path / "other_dir" other_dir.mkdir() (other_dir / "other_file.txt").write_text("other content") - + return subdir, nested, other_dir def test_nested_directory_rename_causes_hang(setup_watcher): """Test renaming deeply nested directories - this is where the hang typically occurs. - + The bug manifests when renaming directories that are nested within other directories, not just top-level directories. """ temp_dir = setup_watcher - + # Create a complex nested structure that mirrors real-world usage # parent/child/grandchild/target_dir/files... parent = temp_dir / "parent_folder" parent.mkdir() - + child = parent / "child_folder" child.mkdir() - + grandchild = child / "grandchild_folder" grandchild.mkdir() - + # This is the directory we'll rename - it's deeply nested target_dir = grandchild / "target_to_rename" target_dir.mkdir() - + # Add files to make the directory scan more complex for i in range(20): (target_dir / f"file_{i:03d}.txt").write_text(f"content_{i}") - + # Add another nested level inside target deep_nested = target_dir / "even_deeper" deep_nested.mkdir() for i in range(10): (deep_nested / f"deep_file_{i}.txt").write_text(f"deep_content_{i}") - + # Initialize watcher state initial_root = watching.walk(PurePosixPath()) watching.state.root = initial_root - + # Verify the nested structure exists - target_path = PurePosixPath("parent_folder/child_folder/grandchild_folder/target_to_rename") + target_path = PurePosixPath( + "parent_folder/child_folder/grandchild_folder/target_to_rename" + ) initial_begin, initial_entries = watching.treeget(initial_root, target_path) - assert initial_begin is not None, "Target directory should be found in initial state" + assert initial_begin is not None, ( + "Target directory should be found in initial state" + ) assert len(initial_entries) > 1, "Target directory should contain files" - + # Now rename the deeply nested directory new_target = grandchild / "renamed_target" target_dir.rename(new_target) - + loop = asyncio.new_event_loop() working_state = watching.state.root[:] - + # This is where the hang likely occurs - updating a deeply nested path - old_nested_path = PurePosixPath("parent_folder/child_folder/grandchild_folder/target_to_rename") - new_nested_path = PurePosixPath("parent_folder/child_folder/grandchild_folder/renamed_target") - + old_nested_path = PurePosixPath( + "parent_folder/child_folder/grandchild_folder/target_to_rename" + ) + new_nested_path = PurePosixPath( + "parent_folder/child_folder/grandchild_folder/renamed_target" + ) + start_time = time.time() - + # Update the old path (should remove it) watching.update_path(working_state, old_nested_path, loop) - + # Update the new path (should add it) watching.update_path(working_state, new_nested_path, loop) - + end_time = time.time() - + # Check for hang - nested operations should still be fast duration = end_time - start_time - assert duration < 3.0, f"Nested directory rename took too long: {duration}s - possible hang" - + assert duration < 3.0, ( + f"Nested directory rename took too long: {duration}s - possible hang" + ) + # Verify the old nested path is gone old_begin, old_entries = watching.treeget(working_state, old_nested_path) assert old_begin is None, "Old nested directory should be removed from tree" - + # Verify the new nested path exists new_begin, new_entries = watching.treeget(working_state, new_nested_path) assert new_begin is not None, "New nested directory should exist in tree" @@ -149,27 +159,27 @@ def test_nested_directory_rename_causes_hang(setup_watcher): def test_move_directory_across_nested_parents(setup_watcher): """Test moving a directory from one nested location to another - high hang risk scenario.""" temp_dir = setup_watcher - + # Create source nested structure source_parent = temp_dir / "source_area" source_parent.mkdir() source_child = source_parent / "source_child" source_child.mkdir() - + # Create the directory to move movable_dir = source_child / "movable_directory" movable_dir.mkdir() - + # Add content to make it more complex for i in range(15): (movable_dir / f"file_{i}.txt").write_text(f"movable_content_{i}") - + # Create a subdirectory within the movable directory sub_movable = movable_dir / "sub_directory" sub_movable.mkdir() for i in range(5): (sub_movable / f"sub_file_{i}.txt").write_text(f"sub_content_{i}") - + # Create destination nested structure dest_parent = temp_dir / "destination_area" dest_parent.mkdir() @@ -177,44 +187,46 @@ def test_move_directory_across_nested_parents(setup_watcher): dest_child.mkdir() dest_grandchild = dest_child / "dest_grandchild" dest_grandchild.mkdir() - + # Initialize state watching.state.root = watching.walk(PurePosixPath()) working_state = watching.state.root[:] - + # Move the directory to the deeply nested destination dest_movable = dest_grandchild / "moved_directory" movable_dir.rename(dest_movable) - + loop = asyncio.new_event_loop() - + # These paths represent the complex nested move operation old_path = PurePosixPath("source_area/source_child/movable_directory") - new_path = PurePosixPath("destination_area/dest_child/dest_grandchild/moved_directory") - + new_path = PurePosixPath( + "destination_area/dest_child/dest_grandchild/moved_directory" + ) + start_time = time.time() - + # This sequence is where hangs typically occur with cross-directory moves try: # Remove from old location watching.update_path(working_state, old_path, loop) - - # Add to new location + + # Add to new location watching.update_path(working_state, new_path, loop) - + except Exception as e: pytest.fail(f"Nested directory move failed: {e}") - + end_time = time.time() duration = end_time - start_time - + # Should complete without hanging assert duration < 5.0, f"Cross-nested move took too long: {duration}s" - + # Verify old location is empty old_begin, old_entries = watching.treeget(working_state, old_path) assert old_begin is None, "Directory should be removed from old nested location" - + # Verify new location has the directory new_begin, new_entries = watching.treeget(working_state, new_path) assert new_begin is not None, "Directory should exist in new nested location" @@ -224,7 +236,7 @@ def test_move_directory_across_nested_parents(setup_watcher): def test_rapid_nested_directory_operations_cause_corruption(setup_watcher): """Test rapid operations on nested directories that can cause state corruption.""" temp_dir = setup_watcher - + # Create multiple nested structures structures = [] for i in range(3): @@ -236,53 +248,55 @@ def test_rapid_nested_directory_operations_cause_corruption(setup_watcher): level3.mkdir() target = level3 / f"target_{i}" target.mkdir() - + # Add files for j in range(10): (target / f"file_{j}.txt").write_text(f"content_{i}_{j}") - + structures.append((level1, level2, level3, target)) - + # Initialize state watching.state.root = watching.walk(PurePosixPath()) working_state = watching.state.root[:] - + loop = asyncio.new_event_loop() - + # Perform rapid nested operations that can cause race conditions operations = [] - + for i, (level1, level2, level3, target) in enumerate(structures): # Rename the deeply nested target new_target = level3 / f"renamed_target_{i}" target.rename(new_target) - + old_path = PurePosixPath(f"level1_{i}/level2_{i}/level3_{i}/target_{i}") new_path = PurePosixPath(f"level1_{i}/level2_{i}/level3_{i}/renamed_target_{i}") operations.append((old_path, new_path)) - + start_time = time.time() - + # Process all operations rapidly - this can cause state corruption/hangs for old_path, new_path in operations: try: watching.update_path(working_state, old_path, loop) watching.update_path(working_state, new_path, loop) except Exception as e: - pytest.fail(f"Rapid nested operations failed for {old_path} -> {new_path}: {e}") - + pytest.fail( + f"Rapid nested operations failed for {old_path} -> {new_path}: {e}" + ) + end_time = time.time() duration = end_time - start_time - + # Should complete without hanging even with rapid operations assert duration < 10.0, f"Rapid nested operations took too long: {duration}s" - + # Verify final state consistency for i, (old_path, new_path) in enumerate(operations): # Old paths should be gone old_begin, old_entries = watching.treeget(working_state, old_path) assert old_begin is None, f"Old path {old_path} should be removed" - + # New paths should exist new_begin, new_entries = watching.treeget(working_state, new_path) assert new_begin is not None, f"New path {new_path} should exist" @@ -291,112 +305,115 @@ def test_rapid_nested_directory_operations_cause_corruption(setup_watcher): def test_nested_directory_treeget_corruption(setup_watcher): """Test that treeget function handles nested path operations correctly without corruption.""" temp_dir = setup_watcher - + # Create a complex tree structure root_dirs = [] for i in range(3): root_dir = temp_dir / f"root_{i}" root_dir.mkdir() - + for j in range(2): mid_dir = root_dir / f"mid_{j}" mid_dir.mkdir() - + for k in range(2): leaf_dir = mid_dir / f"leaf_{k}" leaf_dir.mkdir() - + # Add files to leaf directories for l in range(5): (leaf_dir / f"file_{l}.txt").write_text(f"content_{i}_{j}_{k}_{l}") - + root_dirs.append(root_dir) - + # Initialize state initial_root = watching.walk(PurePosixPath()) watching.state.root = initial_root - + # Test treeget with various nested paths test_paths = [ PurePosixPath("root_0"), - PurePosixPath("root_0/mid_0"), + PurePosixPath("root_0/mid_0"), PurePosixPath("root_0/mid_0/leaf_0"), PurePosixPath("root_1/mid_1/leaf_1"), PurePosixPath("root_2/mid_0/leaf_1"), ] - + # Verify treeget works correctly for all paths for path in test_paths: begin, entries = watching.treeget(initial_root, path) assert begin is not None, f"treeget should find existing path: {path}" assert len(entries) >= 1, f"treeget should return entries for: {path}" - + # Now rename a nested directory and test treeget consistency old_leaf = temp_dir / "root_0" / "mid_0" / "leaf_0" new_leaf = temp_dir / "root_0" / "mid_0" / "renamed_leaf" old_leaf.rename(new_leaf) - + # Update the state loop = asyncio.new_event_loop() working_state = initial_root[:] - + old_nested_path = PurePosixPath("root_0/mid_0/leaf_0") new_nested_path = PurePosixPath("root_0/mid_0/renamed_leaf") - + # Update paths watching.update_path(working_state, old_nested_path, loop) watching.update_path(working_state, new_nested_path, loop) - + # Verify treeget consistency after the update old_begin, old_entries = watching.treeget(working_state, old_nested_path) assert old_begin is None, "Old nested path should not be found after rename" - + new_begin, new_entries = watching.treeget(working_state, new_nested_path) assert new_begin is not None, "New nested path should be found after rename" assert len(new_entries) >= 1, "New nested path should have entries" - + # Verify that other paths are still accessible (no corruption) - for path in [PurePosixPath("root_1/mid_1/leaf_1"), PurePosixPath("root_2/mid_0/leaf_1")]: + for path in [ + PurePosixPath("root_1/mid_1/leaf_1"), + PurePosixPath("root_2/mid_0/leaf_1"), + ]: begin, entries = watching.treeget(working_state, path) assert begin is not None, f"Other paths should remain accessible: {path}" def test_format_update_infinite_loop_with_complex_nested_changes(setup_watcher): """Create a scenario that specifically triggers infinite loops in format_update. - + The hang often occurs in format_update when the diff algorithm gets confused by complex nested directory moves. """ temp_dir = setup_watcher - + # Create a complex scenario that can confuse the diff algorithm # Multiple directories with similar names and nested structures dirs_data = [] - + for i in range(4): # Create main directory main_dir = temp_dir / f"main_{i}" main_dir.mkdir() - + # Create subdirectories with similar patterns sub_dir = main_dir / "common_subdir_name" sub_dir.mkdir() - + # Create files with varying content for j in range(15): (sub_dir / f"file_{j:02d}.txt").write_text(f"main_{i}_content_{j}") - + # Add another level of nesting nested = sub_dir / "nested_level" nested.mkdir() for j in range(8): (nested / f"nested_{j}.txt").write_text(f"nested_{i}_{j}") - + dirs_data.append((main_dir, sub_dir, nested)) - + # Get initial state old_state = watching.walk(PurePosixPath()) - + # Perform complex renames that can confuse the diff algorithm # Rename all subdirectories to have even more similar names for i, (main_dir, sub_dir, nested) in enumerate(dirs_data): @@ -404,49 +421,53 @@ def test_format_update_infinite_loop_with_complex_nested_changes(setup_watcher): new_sub_name = f"renamed_common_subdir_{i}" new_sub_dir = main_dir / new_sub_name sub_dir.rename(new_sub_dir) - + # Also rename some files to create more confusion for j in range(0, 10, 2): # Rename every other file old_file = new_sub_dir / f"file_{j:02d}.txt" new_file = new_sub_dir / f"renamed_file_{j:02d}.txt" if old_file.exists(): old_file.rename(new_file) - + # Get new state new_state = watching.walk(PurePosixPath()) - + # This is the critical test - format_update with complex nested changes # that have caused infinite loops in the past start_time = time.time() - + try: # Set a more aggressive timeout def timeout_handler(signum, frame): raise TimeoutError("format_update appears to be hanging") - + # Set a 10-second timeout signal.signal(signal.SIGALRM, timeout_handler) signal.alarm(10) - + try: update_msg = watching.format_update(old_state, new_state) signal.alarm(0) # Cancel the alarm - + end_time = time.time() duration = end_time - start_time - + # Even complex diffs should complete quickly - assert duration < 8.0, f"format_update took {duration}s - possible infinite loop" - + assert duration < 8.0, ( + f"format_update took {duration}s - possible infinite loop" + ) + # Verify the result is valid assert update_msg, "format_update should return a message" decoded = msgspec.json.decode(update_msg, type=UpdateMessage) assert decoded.update, "Update should contain operations" - + except TimeoutError: signal.alarm(0) - pytest.fail("format_update hung/infinite loop detected with complex nested changes") - + pytest.fail( + "format_update hung/infinite loop detected with complex nested changes" + ) + except Exception as e: signal.alarm(0) pytest.fail(f"format_update failed: {e}") @@ -455,7 +476,7 @@ def test_format_update_infinite_loop_with_complex_nested_changes(setup_watcher): def test_update_path_with_corrupted_tree_state(setup_watcher): """Test update_path when the tree state becomes corrupted by rapid changes.""" temp_dir = setup_watcher - + # Create a nested structure parent = temp_dir / "parent" parent.mkdir() @@ -463,57 +484,57 @@ def test_update_path_with_corrupted_tree_state(setup_watcher): child.mkdir() target = child / "target_dir" target.mkdir() - + # Add many files to make operations slower for i in range(30): (target / f"file_{i:03d}.txt").write_text(f"content_{i}") - + # Add nested subdirectories for i in range(3): subdir = target / f"subdir_{i}" subdir.mkdir() for j in range(10): (subdir / f"sub_file_{j}.txt").write_text(f"sub_content_{i}_{j}") - + # Initialize state watching.state.root = watching.walk(PurePosixPath()) - + # Create a working copy that we'll manually corrupt to simulate race conditions working_state = watching.state.root[:] - + loop = asyncio.new_event_loop() - + # Rename the directory new_target = child / "renamed_target" target.rename(new_target) - + # Simulate the race condition by manually corrupting the tree state # This mimics what happens when inotify events arrive out of order - + # First, try to update a path that should exist old_path = PurePosixPath("parent/child/target_dir") - + # Manually remove an entry to simulate corruption if len(working_state) > 5: # Remove a random entry to corrupt the tree structure del working_state[3] - + start_time = time.time() - + try: # This should handle corrupted state gracefully watching.update_path(working_state, old_path, loop) - + # Now add the new path new_path = PurePosixPath("parent/child/renamed_target") watching.update_path(working_state, new_path, loop) - + end_time = time.time() duration = end_time - start_time - + # Should complete without hanging even with corrupted state assert duration < 5.0, f"update_path with corrupted state took {duration}s" - + except Exception as e: # Some exceptions are expected with corrupted state, but shouldn't hang end_time = time.time() @@ -524,141 +545,143 @@ def test_update_path_with_corrupted_tree_state(setup_watcher): def test_simulate_real_inotify_event_sequence(setup_watcher): """Simulate the exact inotify event sequence that causes hangs.""" temp_dir = setup_watcher - + # Create the exact scenario from real usage that triggers the bug project_dir = temp_dir / "project" project_dir.mkdir() - + src_dir = project_dir / "src" src_dir.mkdir() - + components_dir = src_dir / "components" components_dir.mkdir() - + # This is the directory that will be renamed old_component = components_dir / "OldComponent" old_component.mkdir() - + # Add files that exist in real projects for filename in ["index.tsx", "styles.css", "types.ts", "utils.ts"]: (old_component / filename).write_text(f"// {filename} content") - + # Add a subdirectory with more files sub_dir = old_component / "subcomponents" sub_dir.mkdir() for i in range(5): (sub_dir / f"SubComponent{i}.tsx").write_text(f"// SubComponent{i}") - + # Initialize state watching.state.root = watching.walk(PurePosixPath()) working_state = watching.state.root[:] - + loop = asyncio.new_event_loop() - + # This is the exact operation that causes hangs in real usage new_component = components_dir / "NewComponent" old_component.rename(new_component) - + # Simulate the inotify event sequence that causes problems # IN_MOVED_FROM event for the old directory old_path = PurePosixPath("project/src/components/OldComponent") - - # IN_MOVED_TO event for the new directory + + # IN_MOVED_TO event for the new directory new_path = PurePosixPath("project/src/components/NewComponent") - + # Track how long the operations take start_time = time.time() - + # Set up timeout detection def timeout_handler(signum, frame): raise TimeoutError("Simulated inotify sequence hung") - + signal.signal(signal.SIGALRM, timeout_handler) signal.alarm(15) # 15 second timeout - + try: # This sequence is where the hang occurs in real usage watching.update_path(working_state, old_path, loop) watching.update_path(working_state, new_path, loop) - + # If we get here without hanging, cancel the alarm signal.alarm(0) - + end_time = time.time() duration = end_time - start_time - + # Real inotify operations should be fast assert duration < 10.0, f"Simulated inotify sequence took {duration}s" - + # Verify the final state is correct old_begin, old_entries = watching.treeget(working_state, old_path) assert old_begin is None, "Old component path should be removed" - + new_begin, new_entries = watching.treeget(working_state, new_path) assert new_begin is not None, "New component path should exist" assert len(new_entries) > 1, "New component should contain all files" - + except TimeoutError: signal.alarm(0) pytest.fail("HANG DETECTED: Simulated inotify event sequence hung!") - + except Exception as e: signal.alarm(0) pytest.fail(f"Simulated inotify sequence failed: {e}") - + finally: signal.alarm(0) # Ensure alarm is cancelled """Test format_update with nested directory changes that could cause infinite loops.""" temp_dir = setup_watcher - + # Create complex nested structure that has caused issues complex_structure = temp_dir / "complex" complex_structure.mkdir() - + # Create multiple levels with similar names (potential for confusion) level_a = complex_structure / "level_a" level_a.mkdir() sublevel_a = level_a / "sublevel" sublevel_a.mkdir() - - level_b = complex_structure / "level_b" + + level_b = complex_structure / "level_b" level_b.mkdir() sublevel_b = level_b / "sublevel" sublevel_b.mkdir() - + # Add files to each sublevel for i in range(10): (sublevel_a / f"file_a_{i}.txt").write_text(f"content_a_{i}") (sublevel_b / f"file_b_{i}.txt").write_text(f"content_b_{i}") - + # Get initial state old_state = watching.walk(PurePosixPath()) - + # Perform nested directory renames that could confuse the diff algorithm renamed_sublevel_a = level_a / "renamed_sublevel" sublevel_a.rename(renamed_sublevel_a) - - renamed_sublevel_b = level_b / "also_renamed_sublevel" + + renamed_sublevel_b = level_b / "also_renamed_sublevel" sublevel_b.rename(renamed_sublevel_b) - + # Get new state new_state = watching.walk(PurePosixPath()) - + # This is where infinite loops or hangs can occur in format_update start_time = time.time() - + try: update_msg = watching.format_update(old_state, new_state) end_time = time.time() - + duration = end_time - start_time - assert duration < 5.0, f"format_update took too long with nested changes: {duration}s" - + assert duration < 5.0, ( + f"format_update took too long with nested changes: {duration}s" + ) + # Verify the update message is valid assert update_msg, "format_update should return valid message" decoded = msgspec.json.decode(update_msg, type=UpdateMessage) assert decoded.update, "Update should contain operations" - + except Exception as e: pytest.fail(f"format_update failed or hung with nested directory changes: {e}") """Test that reproduces the hang when directory rename events race with updates. @@ -670,65 +693,65 @@ def test_simulate_real_inotify_event_sequence(setup_watcher): 4. This should cause a hang where old directory names are preserved """ temp_dir = setup_watcher - + # Create test structure with many files to increase chance of race conditions subdir = temp_dir / "original_dir" subdir.mkdir() - + # Create many files to make the directory scan take longer for i in range(50): (subdir / f"file_{i:03d}.txt").write_text(f"content_{i}") - + # Create nested directories nested = subdir / "nested" nested.mkdir() for i in range(20): (nested / f"nested_file_{i:03d}.txt").write_text(f"nested_content_{i}") - + # Initial scan to populate the state initial_root = watching.walk(PurePosixPath()) watching.state.root = initial_root - + # Verify initial structure initial_names = [entry.name for entry in initial_root] assert "original_dir" in initial_names - + # Create a mock event loop for testing loop = asyncio.new_event_loop() - + # Simulate the problematic sequence: # 1. Start processing the original directory # 2. Rename it while processing # 3. Try to update both old and new paths - + # Start by getting the initial state original_rootmod = watching.state.root[:] - - # Rename the directory + + # Rename the directory renamed_dir = temp_dir / "renamed_dir" subdir.rename(renamed_dir) - + # Now simulate what happens in the inotify watcher: # Multiple rapid updates that can cause race conditions - + # First, try to update the old path (should remove it) watching.update_path(original_rootmod, PurePosixPath("original_dir"), loop) - + # Then try to update the new path (should add it) watching.update_path(original_rootmod, PurePosixPath("renamed_dir"), loop) - + # Check if the state is consistent final_names = [entry.name for entry in original_rootmod] - + # The bug would manifest as: # 1. Old directory name still present (should be gone) # 2. New directory name missing (should be there) # 3. Inconsistent state causing hangs - + # This is the expected correct behavior assert "original_dir" not in final_names, "Old directory name should be removed" assert "renamed_dir" in final_names, "New directory name should be present" - + # Additional check: verify we can still walk the renamed directory renamed_walk = watching.walk(PurePosixPath("renamed_dir")) assert len(renamed_walk) > 1, "Should be able to walk renamed directory" @@ -737,11 +760,11 @@ def test_simulate_real_inotify_event_sequence(setup_watcher): def test_concurrent_inotify_events_simulation(setup_watcher): """Simulate concurrent inotify events that can cause the hanging bug.""" temp_dir = setup_watcher - + # Create a complex directory structure dirs = ["dir_a", "dir_b", "dir_c"] created_dirs = [] - + for dir_name in dirs: dir_path = temp_dir / dir_name dir_path.mkdir() @@ -749,29 +772,29 @@ def test_concurrent_inotify_events_simulation(setup_watcher): for i in range(10): (dir_path / f"file_{i}.txt").write_text(f"content in {dir_name}") created_dirs.append(dir_path) - + # Initial state watching.state.root = watching.walk(PurePosixPath()) original_state = watching.state.root[:] - + loop = asyncio.new_event_loop() - + # Simulate rapid concurrent operations that happen in real usage # This mimics what happens when multiple filesystem events arrive rapidly - + # Rename all directories simultaneously (as might happen with mv commands) renamed_paths = [] for i, dir_path in enumerate(created_dirs): new_path = temp_dir / f"renamed_{dirs[i]}" dir_path.rename(new_path) renamed_paths.append(new_path) - + # Now simulate the inotify event processing that causes issues # In the real code, these updates happen in rapid succession # and can cause race conditions - + working_state = original_state[:] - + # Process removal events (IN_MOVED_FROM) for dir_name in dirs: try: @@ -779,7 +802,7 @@ def test_concurrent_inotify_events_simulation(setup_watcher): except Exception as e: # The bug might manifest as exceptions during updates pytest.fail(f"Update path failed for {dir_name}: {e}") - + # Process addition events (IN_MOVED_TO) for i, dir_name in enumerate(dirs): try: @@ -787,14 +810,16 @@ def test_concurrent_inotify_events_simulation(setup_watcher): watching.update_path(working_state, PurePosixPath(new_name), loop) except Exception as e: pytest.fail(f"Update path failed for {new_name}: {e}") - + # Verify final state is consistent final_names = [entry.name for entry in working_state] - + # Check that old names are gone for dir_name in dirs: - assert dir_name not in final_names, f"Old directory {dir_name} should be removed" - + assert dir_name not in final_names, ( + f"Old directory {dir_name} should be removed" + ) + # Check that new names are present for i, dir_name in enumerate(dirs): new_name = f"renamed_{dir_name}" @@ -804,92 +829,92 @@ def test_concurrent_inotify_events_simulation(setup_watcher): def test_format_update_with_rapid_changes(setup_watcher): """Test format_update with rapid directory changes that can cause hangs.""" temp_dir = setup_watcher - + # Create initial structure initial_dirs = ["test1", "test2", "test3"] for dir_name in initial_dirs: dir_path = temp_dir / dir_name dir_path.mkdir() (dir_path / "file.txt").write_text("test content") - + # Get initial state old_state = watching.walk(PurePosixPath()) - + # Perform rapid renames for i, dir_name in enumerate(initial_dirs): old_path = temp_dir / dir_name new_path = temp_dir / f"renamed_{dir_name}" old_path.rename(new_path) - + # Get new state new_state = watching.walk(PurePosixPath()) - + # This is where the hang might occur - in format_update start_time = time.time() try: update_msg = watching.format_update(old_state, new_state) end_time = time.time() - + # Should complete quickly duration = end_time - start_time assert duration < 5.0, f"format_update took too long: {duration}s" - + # Decode the update to verify it's valid decoded = msgspec.json.decode(update_msg, type=UpdateMessage) assert decoded.update, "Update message should contain operations" - + except Exception as e: pytest.fail(f"format_update failed or hung: {e}") def test_update_path_with_missing_directory(setup_watcher): """Test update_path when called on a directory that no longer exists. - + This simulates the race condition where update_path is called for a path that was just moved/deleted. """ temp_dir = setup_watcher - + # Create and populate initial state test_dir = temp_dir / "disappearing_dir" test_dir.mkdir() (test_dir / "file.txt").write_text("content") - + initial_state = watching.walk(PurePosixPath()) watching.state.root = initial_state working_state = initial_state[:] - + # Remove the directory shutil.rmtree(test_dir) - + loop = asyncio.new_event_loop() - + # Now try to update the path that no longer exists # This should handle gracefully without hanging start_time = time.time() try: watching.update_path(working_state, PurePosixPath("disappearing_dir"), loop) end_time = time.time() - + duration = end_time - start_time assert duration < 2.0, f"update_path took too long: {duration}s" - + # Verify the directory was removed from the state final_names = [entry.name for entry in working_state] assert "disappearing_dir" not in final_names - + except Exception as e: pytest.fail(f"update_path should handle missing directories gracefully: {e}") def test_threaded_watcher_simulation(setup_watcher): """Test that simulates the actual threaded watcher behavior with directory renames. - + This test creates a more realistic scenario where the watcher thread processes events while filesystem operations are happening. """ temp_dir = setup_watcher - + # Create test structure test_dirs = [] for i in range(5): @@ -899,107 +924,110 @@ def test_threaded_watcher_simulation(setup_watcher): for j in range(5): (dir_path / f"file_{j}.txt").write_text(f"content_{i}_{j}") test_dirs.append(dir_path) - + # Initialize state watching.state.root = watching.walk(PurePosixPath()) - + # Create an event loop for the simulation loop = asyncio.new_event_loop() - + # Track state changes state_changes = [] original_broadcast = watching.broadcast - + def tracking_broadcast(msg, loop_param): state_changes.append(msg) return original_broadcast(msg, loop_param) - + # Patch broadcast to track changes with patch("cista.watching.broadcast", side_effect=tracking_broadcast): - # Simulate rapid directory operations start_time = time.time() - + for i, dir_path in enumerate(test_dirs): # Rename directory new_path = temp_dir / f"renamed_thread_test_dir_{i}" dir_path.rename(new_path) - + # Update the watcher state (simulating inotify events) old_name = f"thread_test_dir_{i}" new_name = f"renamed_thread_test_dir_{i}" - + # Simulate the race condition: rapid updates watching.update_path(watching.state.root, PurePosixPath(old_name), loop) watching.update_path(watching.state.root, PurePosixPath(new_name), loop) - + end_time = time.time() - + # Should complete without hanging duration = end_time - start_time assert duration < 10.0, f"Threaded operations took too long: {duration}s" - + # Verify final state is consistent final_names = [entry.name for entry in watching.state.root] - + # Old names should be gone for i in range(5): old_name = f"thread_test_dir_{i}" - assert old_name not in final_names, f"Old directory {old_name} should be removed" - + assert old_name not in final_names, ( + f"Old directory {old_name} should be removed" + ) + # New names should be present for i in range(5): new_name = f"renamed_thread_test_dir_{i}" - assert new_name in final_names, f"New directory {new_name} should be present" + assert new_name in final_names, ( + f"New directory {new_name} should be present" + ) def test_directory_rename_with_nested_structure(setup_watcher): """Test renaming a directory that contains nested subdirectories.""" temp_dir = setup_watcher - + # Create a more complex nested structure main_dir = temp_dir / "main_dir" main_dir.mkdir() - + # Create multiple levels of nesting level1 = main_dir / "level1" level1.mkdir() (level1 / "l1_file.txt").write_text("level1 content") - + level2 = level1 / "level2" level2.mkdir() (level2 / "l2_file.txt").write_text("level2 content") - + level3 = level2 / "level3" level3.mkdir() (level3 / "l3_file.txt").write_text("level3 content") - + # Initial scan initial_root = watching.walk(PurePosixPath()) watching.state.root = initial_root - + # Rename the main directory renamed_main = temp_dir / "renamed_main_dir" main_dir.rename(renamed_main) - + # Update the watching system loop = asyncio.new_event_loop() watching.update_path(watching.state.root, PurePosixPath("main_dir"), loop) watching.update_path(watching.state.root, PurePosixPath("renamed_main_dir"), loop) - + # Verify the entire nested structure is properly updated updated_root = watching.state.root updated_names = [entry.name for entry in updated_root] - + assert "main_dir" not in updated_names assert "renamed_main_dir" in updated_names - + # Verify the nested structure is still intact renamed_structure = watching.walk(PurePosixPath("renamed_main_dir")) - + # Extract all the names from the renamed structure all_names = [entry.name for entry in renamed_structure] - + # Should contain the directory itself and all nested items assert "renamed_main_dir" in all_names assert "level1" in all_names @@ -1013,31 +1041,31 @@ def test_directory_rename_with_nested_structure(setup_watcher): def test_directory_rename_format_update(setup_watcher): """Test that format_update correctly handles directory renames.""" temp_dir = setup_watcher - + # Create test structure subdir, _, other_dir = create_test_structure(temp_dir) - + # Get initial state old_root = watching.walk(PurePosixPath()) - + # Rename directory renamed_subdir = temp_dir / "renamed_subdir" subdir.rename(renamed_subdir) - + # Get new state new_root = watching.walk(PurePosixPath()) - + # Generate update message update_msg = watching.format_update(old_root, new_root) - + # The update should not be empty and should contain proper operations assert update_msg assert "update" in update_msg - + # Decode and verify the update contains expected operations decoded = msgspec.json.decode(update_msg, type=UpdateMessage) assert decoded.update # Should have update operations - + # The update should reflect the rename operation (delete old, insert new) operations = decoded.update assert len(operations) > 0 @@ -1046,102 +1074,109 @@ def test_directory_rename_format_update(setup_watcher): def test_concurrent_directory_operations(setup_watcher): """Test behavior when multiple directory operations happen concurrently.""" temp_dir = setup_watcher - + # Create multiple directories dirs_to_create = ["dir1", "dir2", "dir3"] created_dirs = [] - + for dir_name in dirs_to_create: dir_path = temp_dir / dir_name dir_path.mkdir() (dir_path / f"{dir_name}_file.txt").write_text(f"content for {dir_name}") created_dirs.append(dir_path) - + # Initial scan initial_root = watching.walk(PurePosixPath()) watching.state.root = initial_root - + # Rename multiple directories "simultaneously" renamed_dirs = [] for i, dir_path in enumerate(created_dirs): - renamed_path = temp_dir / f"renamed_dir{i+1}" + renamed_path = temp_dir / f"renamed_dir{i + 1}" dir_path.rename(renamed_path) renamed_dirs.append(renamed_path) - + # Update the watching system for all changes loop = asyncio.new_event_loop() - + # Update for all old paths (should remove them) for dir_name in dirs_to_create: watching.update_path(watching.state.root, PurePosixPath(dir_name), loop) - + # Update for all new paths (should add them) for i in range(len(renamed_dirs)): - watching.update_path(watching.state.root, PurePosixPath(f"renamed_dir{i+1}"), loop) - + watching.update_path( + watching.state.root, PurePosixPath(f"renamed_dir{i + 1}"), loop + ) + # Verify final state final_root = watching.state.root final_names = [entry.name for entry in final_root] - + # Old names should be gone for dir_name in dirs_to_create: assert dir_name not in final_names - + # New names should be present for i in range(len(renamed_dirs)): - assert f"renamed_dir{i+1}" in final_names + assert f"renamed_dir{i + 1}" in final_names @pytest.mark.slow def test_watcher_doesnt_hang_on_directory_rename(setup_watcher): """Test that the watcher doesn't hang when a directory is renamed. - + This test specifically addresses the reported bug where directory renames cause the system to hang and no more operations go through. """ temp_dir = setup_watcher - + # Create test structure subdir, _, _ = create_test_structure(temp_dir) - + # Initialize the watcher state watching.state.root = watching.walk(PurePosixPath()) - + # Mock the inotify events to simulate what happens during a rename # This simulates the problematic scenario described in the bug report - with patch('time.monotonic', side_effect=[0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6]): - + with patch("time.monotonic", side_effect=[0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6]): # Simulate the rename operation renamed_subdir = temp_dir / "renamed_test_subdir" subdir.rename(renamed_subdir) - + # Create a simple event loop for testing loop = asyncio.new_event_loop() - + # This should complete without hanging start_time = time.time() - + # Update the path - this is where the hang might occur watching.update_path(watching.state.root, PurePosixPath("test_subdir"), loop) - watching.update_path(watching.state.root, PurePosixPath("renamed_test_subdir"), loop) - + watching.update_path( + watching.state.root, PurePosixPath("renamed_test_subdir"), loop + ) + end_time = time.time() - + # The operation should complete quickly (within 5 seconds) - assert end_time - start_time < 5.0, "Directory rename operation took too long, possible hang detected" - + assert end_time - start_time < 5.0, ( + "Directory rename operation took too long, possible hang detected" + ) + # Verify the state is consistent final_names = [entry.name for entry in watching.state.root] assert "test_subdir" not in final_names assert "renamed_test_subdir" in final_names - + # Verify we can still perform operations after the rename # This tests that the system isn't in a broken state another_dir = temp_dir / "post_rename_dir" another_dir.mkdir() - + # This should work without issues - watching.update_path(watching.state.root, PurePosixPath("post_rename_dir"), loop) + watching.update_path( + watching.state.root, PurePosixPath("post_rename_dir"), loop + ) final_names_after = [entry.name for entry in watching.state.root] assert "post_rename_dir" in final_names_after