Repository: microsoft/ptvsd

Issue:
Could not stop at breakpoint when pathMappings.localRoot has slash
## Environment data

- PTVSD version: 4.1.1a11
- OS and version: Windows 10
- Python version (& distribution if applicable, e.g. Anaconda): Python2
- Using VS Code or Visual Studio: VS Code

## Actual behavior

Could not map file correctly to stop at breakpoint when `pathMappings.localRoot` has slash

## Steps to reproduce:
1. git clone https://github.com/formulahendry/python-amd64-debug.git
2. docker build --rm -f Dockerfile.amd64.debug -t python-amd64-debug:latest .
3. docker run -d -p 4000:3000 --name python-test python-amd64-debug:latest
4. Attach the python code with the 3 configurations in `launch.json`
```json
{
            "name": "Python Experimental: Attach (working)",
            "type": "pythonExperimental",
            "request": "attach",
            "port": 4000,
            "host": "localhost",
            "pathMappings": [{
                "localRoot": "${workspaceFolder}", // Local root  (where source and debugger running)
                "remoteRoot": "/app" // Remote root (where remote code is running)
            }]
        },
        {
            "name": "Python Experimental: Attach (sub folder) (working)",
            "type": "pythonExperimental",
            "request": "attach",
            "port": 4000,
            "host": "localhost",
            "pathMappings": [{
                "localRoot": "${workspaceFolder}\\test", // Local root  (where source and debugger running)
                "remoteRoot": "/app" // Remote root (where remote code is running)
            }]
        },
        {
            "name": "Python Experimental: Attach (sub folder) (not working)",
            "type": "pythonExperimental",
            "request": "attach",
            "port": 4000,
            "host": "localhost",
            "pathMappings": [{
                "localRoot": "${workspaceFolder}/test", // Local root  (where source and debugger running)
                "remoteRoot": "/app" // Remote root (where remote code is running)
            }]
        }
```
5. The `Python Experimental: Attach (sub folder) (not working)` is not working, while other two work
![image](https://user-images.githubusercontent.com/1050213/44330391-6e2a0800-a499-11e8-9c86-cf1c6a58f07a.png)




Discussion:
Our CTI also had an issue with `${workspaceFolder}` where it ended up being translated to `/C:/test/temotedebug.py` (note the leading slash and path separators). When she set `localRoot` to `C:\\test\\` it apparently worked. (She was trying remote debugging from Windows to Linux.)

Resolving patch:
diff --git a/ptvsd/_vendored/pydevd/pydevd_file_utils.py b/ptvsd/_vendored/pydevd/pydevd_file_utils.py
--- a/ptvsd/_vendored/pydevd/pydevd_file_utils.py
+++ b/ptvsd/_vendored/pydevd/pydevd_file_utils.py
@@ -144,8 +144,9 @@ def _get_path_with_real_case(filename):
         convert_to_long_pathname = _convert_to_long_pathname
         convert_to_short_pathname = _convert_to_short_pathname
         get_path_with_real_case = _get_path_with_real_case
-        
+
 elif IS_JYTHON and IS_WINDOWS:
+
     def get_path_with_real_case(filename):
         from java.io import File
         f = File(filename)
@@ -153,14 +154,14 @@ def get_path_with_real_case(filename):
         if IS_PY2 and not isinstance(ret, str):
             return ret.encode(getfilesystemencoding())
         return ret
-    
 
 if IS_WINDOWS:
 
     if IS_JYTHON:
+
         def normcase(filename):
             return filename.lower()
-        
+
     else:
 
         def normcase(filename):
@@ -168,7 +169,7 @@ def normcase(filename):
             # side does it, so we should do it manually.
             if '~' in filename:
                 filename = convert_to_long_pathname(filename)
-    
+
             filename = _os_normcase(filename)
             return filename.lower()
 
@@ -189,16 +190,20 @@ def set_ide_os(os):
     :param os:
         'UNIX' or 'WINDOWS'
     '''
+    global _ide_os
+    prev = _ide_os
     if os == 'WIN':  # Apparently PyCharm uses 'WIN' (https://github.com/fabioz/PyDev.Debugger/issues/116)
         os = 'WINDOWS'
-    
+
     assert os in ('WINDOWS', 'UNIX')
 
-    global _ide_os
-    _ide_os = os
+    if prev != os:
+        _ide_os = os
+        # We need to (re)setup how the client <-> server translation works to provide proper separators.
+        setup_client_server_paths(_last_client_server_paths_set)
 
 
-DEBUG_CLIENT_SERVER_TRANSLATION = False
+DEBUG_CLIENT_SERVER_TRANSLATION = os.environ.get('DEBUG_PYDEVD_PATHS_TRANSLATION', 'False').lower() in ('1', 'true')
 
 # Caches filled as requested during the debug session.
 NORM_PATHS_CONTAINER = {}
@@ -372,11 +377,29 @@ def _original_file_to_client(filename, cache={}):
 norm_file_to_server = _original_file_to_server
 
 
+def _fix_path(path, sep):
+    if path.endswith('/') or path.endswith('\\'):
+        path = path[:-1]
+
+    if sep != '/':
+        path = path.replace('/', sep)
+    return path
+
+
+_last_client_server_paths_set = []
+
+
 def setup_client_server_paths(paths):
     '''paths is the same format as PATHS_FROM_ECLIPSE_TO_PYTHON'''
 
     global norm_file_to_client
     global norm_file_to_server
+    global _last_client_server_paths_set
+    _last_client_server_paths_set = paths[:]
+
+    # Work on the client and server slashes.
+    python_sep = '\\' if IS_WINDOWS else '/'
+    eclipse_sep = '\\' if _ide_os == 'WINDOWS' else '/'
 
     norm_filename_to_server_container = {}
     norm_filename_to_client_container = {}
@@ -391,6 +414,10 @@ def setup_client_server_paths(paths):
                 path0 = path0.encode(sys.getfilesystemencoding())
             if isinstance(path1, unicode):
                 path1 = path1.encode(sys.getfilesystemencoding())
+                
+        path0 = _fix_path(path0, eclipse_sep)
+        path1 = _fix_path(path1, python_sep)
+        initial_paths[i] = (path0, path1)
 
         paths_from_eclipse_to_python[i] = (normcase(path0), normcase(path1))
 
@@ -400,19 +427,17 @@ def setup_client_server_paths(paths):
         norm_file_to_server = _original_file_to_server
         return
 
-    # Work on the client and server slashes.
-    python_sep = '\\' if IS_WINDOWS else '/'
-
     # only setup translation functions if absolutely needed!
-    def _norm_file_to_server(filename):
+    def _norm_file_to_server(filename, cache=norm_filename_to_server_container):
         # Eclipse will send the passed filename to be translated to the python process
         # So, this would be 'NormFileFromEclipseToPython'
         try:
-            return norm_filename_to_server_container[filename]
+            return cache[filename]
         except KeyError:
-            # Note: compute eclipse_sep lazily so that _ide_os is taken into account.
-            # (https://www.brainwy.com/tracker/PyDev/930)
-            eclipse_sep = '\\' if _ide_os == 'WINDOWS' else '/'
+            if eclipse_sep != python_sep:
+                # Make sure that the separators are what we expect from the IDE.
+                filename = filename.replace(python_sep, eclipse_sep)
+
             # used to translate a path from the client to the debug server
             translated = normcase(filename)
             for eclipse_prefix, server_prefix in paths_from_eclipse_to_python:
@@ -433,19 +458,15 @@ def _norm_file_to_server(filename):
                 translated = translated.replace(eclipse_sep, python_sep)
             translated = _NormFile(translated)
 
-            norm_filename_to_server_container[filename] = translated
+            cache[filename] = translated
             return translated
 
-    def _norm_file_to_client(filename):
+    def _norm_file_to_client(filename, cache=norm_filename_to_client_container):
         # The result of this method will be passed to eclipse
         # So, this would be 'NormFileFromPythonToEclipse'
         try:
-            return norm_filename_to_client_container[filename]
+            return cache[filename]
         except KeyError:
-            # Note: compute eclipse_sep lazily so that _ide_os is taken into account.
-            # (https://www.brainwy.com/tracker/PyDev/930)
-            eclipse_sep = '\\' if _ide_os == 'WINDOWS' else '/'
-            
             # used to translate a path from the debug server to the client
             translated = _NormFile(filename)
 
@@ -485,7 +506,7 @@ def _norm_file_to_client(filename):
 
             # The resulting path is not in the python process, so, we cannot do a _NormFile here,
             # only at the beginning of this method.
-            norm_filename_to_client_container[filename] = translated
+            cache[filename] = translated
             return translated
 
     norm_file_to_server = _norm_file_to_server
@@ -550,4 +571,4 @@ def get_package_dir(mod_name):
         mod_path = join(path, mod_name)
         if os.path.isdir(mod_path):
             return mod_path
-    return None
\ No newline at end of file
+    return None


Test patch:
diff --git a/ptvsd/_vendored/pydevd/tests_python/test_convert_utilities.py b/ptvsd/_vendored/pydevd/tests_python/test_convert_utilities.py
--- a/ptvsd/_vendored/pydevd/tests_python/test_convert_utilities.py
+++ b/ptvsd/_vendored/pydevd/tests_python/test_convert_utilities.py
@@ -54,26 +54,48 @@ def check(obtained, expected):
 
             # Client and server are on windows.
             pydevd_file_utils.set_ide_os('WINDOWS')
-            in_eclipse = 'c:\\foo'
-            in_python = 'c:\\bar'
-            PATHS_FROM_ECLIPSE_TO_PYTHON = [
-                (in_eclipse, in_python)
-            ]
-            pydevd_file_utils.setup_client_server_paths(PATHS_FROM_ECLIPSE_TO_PYTHON)
-            check(pydevd_file_utils.norm_file_to_server('c:\\foo\\my'), 'c:\\bar\\my')
-            check(pydevd_file_utils.norm_file_to_server('c:\\foo\\áéíóú'.upper()), 'c:\\bar\\áéíóú')
-            check(pydevd_file_utils.norm_file_to_client('c:\\bar\\my'), 'c:\\foo\\my')
+            for in_eclipse, in_python  in ([
+                    ('c:\\foo', 'c:\\bar'),
+                    ('c:/foo', 'c:\\bar'),
+                    ('c:\\foo', 'c:/bar'),
+                    ('c:\\foo', 'c:\\bar\\'),
+                    ('c:/foo', 'c:\\bar\\'),
+                    ('c:\\foo', 'c:/bar/'),
+                    ('c:\\foo\\', 'c:\\bar'),
+                    ('c:/foo/', 'c:\\bar'),
+                    ('c:\\foo\\', 'c:/bar'),
+                    
+                ]):
+                PATHS_FROM_ECLIPSE_TO_PYTHON = [
+                    (in_eclipse, in_python)
+                ]
+                pydevd_file_utils.setup_client_server_paths(PATHS_FROM_ECLIPSE_TO_PYTHON)
+                check(pydevd_file_utils.norm_file_to_server('c:\\foo\\my'), 'c:\\bar\\my')
+                check(pydevd_file_utils.norm_file_to_server('c:/foo/my'), 'c:\\bar\\my')
+                check(pydevd_file_utils.norm_file_to_server('c:/foo/my/'), 'c:\\bar\\my')
+                check(pydevd_file_utils.norm_file_to_server('c:\\foo\\áéíóú'.upper()), 'c:\\bar\\áéíóú')
+                check(pydevd_file_utils.norm_file_to_client('c:\\bar\\my'), 'c:\\foo\\my')
 
             # Client on unix and server on windows
             pydevd_file_utils.set_ide_os('UNIX')
-            in_eclipse = '/foo'
-            in_python = 'c:\\bar'
-            PATHS_FROM_ECLIPSE_TO_PYTHON = [
-                (in_eclipse, in_python)
-            ]
-            pydevd_file_utils.setup_client_server_paths(PATHS_FROM_ECLIPSE_TO_PYTHON)
-            check(pydevd_file_utils.norm_file_to_server('/foo/my'), 'c:\\bar\\my')
-            check(pydevd_file_utils.norm_file_to_client('c:\\bar\\my'), '/foo/my')
+            for in_eclipse, in_python  in ([
+                    ('/foo', 'c:\\bar'),
+                    ('/foo', 'c:/bar'),
+                    ('/foo', 'c:\\bar\\'),
+                    ('/foo', 'c:/bar/'),
+                    ('/foo/', 'c:\\bar'),
+                    ('/foo/', 'c:\\bar\\'),
+                ]):
+                
+                PATHS_FROM_ECLIPSE_TO_PYTHON = [
+                    (in_eclipse, in_python)
+                ]
+                pydevd_file_utils.setup_client_server_paths(PATHS_FROM_ECLIPSE_TO_PYTHON)
+                check(pydevd_file_utils.norm_file_to_server('/foo/my'), 'c:\\bar\\my')
+                check(pydevd_file_utils.norm_file_to_client('c:\\bar\\my'), '/foo/my')
+                check(pydevd_file_utils.norm_file_to_client('c:\\bar\\my\\'), '/foo/my')
+                check(pydevd_file_utils.norm_file_to_client('c:/bar/my'), '/foo/my')
+                check(pydevd_file_utils.norm_file_to_client('c:/bar/my/'), '/foo/my')
 
             # Test with 'real' files
             # Client and server are on windows.
@@ -116,22 +138,33 @@ def check(obtained, expected):
             pydevd_file_utils.setup_client_server_paths(PATHS_FROM_ECLIPSE_TO_PYTHON)
             assert pydevd_file_utils.norm_file_to_server(test_dir) == test_dir.lower()
             assert pydevd_file_utils.norm_file_to_client(test_dir).endswith('\\Foo')
-
         else:
             # Client on windows and server on unix
             pydevd_file_utils.set_ide_os('WINDOWS')
-            in_eclipse = 'c:\\foo'
-            in_python = '/bar'
-            PATHS_FROM_ECLIPSE_TO_PYTHON = [
-                (in_eclipse, in_python)
-            ]
-            pydevd_file_utils.setup_client_server_paths(PATHS_FROM_ECLIPSE_TO_PYTHON)
-            assert pydevd_file_utils.norm_file_to_server('c:\\foo\\my') == '/bar/my'
-            assert pydevd_file_utils.norm_file_to_client('/bar/my') == 'c:\\foo\\my'
-
-            # Files for which there's no translation have only their separators updated.
-            assert pydevd_file_utils.norm_file_to_client('/usr/bin') == '\\usr\\bin'
-            assert pydevd_file_utils.norm_file_to_server('\\usr\\bin') == '/usr/bin'
+            for in_eclipse, in_python  in ([
+                    ('c:\\foo', '/bar'),
+                    ('c:/foo', '/bar'),
+                    ('c:/foo/', '/bar'),
+                ]):
+                
+                PATHS_FROM_ECLIPSE_TO_PYTHON = [
+                    (in_eclipse, in_python)
+                ]
+
+                pydevd_file_utils.setup_client_server_paths(PATHS_FROM_ECLIPSE_TO_PYTHON)
+                assert pydevd_file_utils.norm_file_to_server('c:\\foo\\my') == '/bar/my'
+                assert pydevd_file_utils.norm_file_to_server('c:/foo/my') == '/bar/my'
+                assert pydevd_file_utils.norm_file_to_server('c:\\foo\\my\\') == '/bar/my'
+                assert pydevd_file_utils.norm_file_to_server('c:/foo/my/') == '/bar/my'
+                
+                assert pydevd_file_utils.norm_file_to_client('/bar/my') == 'c:\\foo\\my'
+                assert pydevd_file_utils.norm_file_to_client('/bar/my/') == 'c:\\foo\\my'
+    
+                # Files for which there's no translation have only their separators updated.
+                assert pydevd_file_utils.norm_file_to_client('/usr/bin') == '\\usr\\bin'
+                assert pydevd_file_utils.norm_file_to_client('/usr/bin/') == '\\usr\\bin'
+                assert pydevd_file_utils.norm_file_to_server('\\usr\\bin') == '/usr/bin'
+                assert pydevd_file_utils.norm_file_to_server('\\usr\\bin\\') == '/usr/bin'
 
             # Client and server on unix
             pydevd_file_utils.set_ide_os('UNIX')