Repository: jupyterhub/jupyterhub

Issue:
404 on single user notebook server anytime I restart jupyterhub
I just deployed jupyterhub from scratch using my ansible scripts this morning. The script are unchanged from 2-3 weeks ago when I did it previously and it was all working. I am running from latest master of jupyterhub.

Anytime I restart jupyterhub (hub+proxy) I see the following behavior:
- I can log in (using GitHub OAuth)
- Trying to start my server gives me a 404 on `/hub/users/ellisonbg`. I can tell from the server log that the single user server isn't being started.
- I can go to the Admin page and start my server from there.
- But then, I get a redirect loop when I try to go to my single user server.
- If I clear all my cookies while my single user server (started through the Admin page) is still running, it starts to work as expected. I can start and stop the single user server on the control panel page just fine.
- If a restart jupyterhub, the problems start all over again.

I am using a fixed proxy_auth_token and cookie secret.



Discussion:
OK tracking this down a bit:

In this line of code there is a redirect to a URL that has `users` in it:

https://github.com/jupyter/jupyterhub/blob/master/jupyterhub/handlers/pages.py#L74

But I don't see any handlers targeting URLs with `users`. I do, however, see one that targets `users` (singular) here:

https://github.com/jupyter/jupyterhub/blob/master/jupyterhub/handlers/base.py#L482

Looks like this is a bug that crept in in the recent spawner form work. I will pin my work to a hash that is before that work for now. More than willing to test fixes for this though.

I just installed this SHA:

https://github.com/jupyter/jupyterhub/commit/2f1a2036999cd5ab02a720af62ea698645d2a8b5

and everything works fine. This doesn't explain exactly what the problem is, but it does bisect it to the recent spawner work.

HTH!

Thanks! I know what's going wrong, just need to figure out why...


Resolving patch:
diff --git a/jupyterhub/handlers/pages.py b/jupyterhub/handlers/pages.py
--- a/jupyterhub/handlers/pages.py
+++ b/jupyterhub/handlers/pages.py
@@ -71,7 +71,7 @@ def get(self):
             self.finish(html)
         else:
             # not running, no form. Trigger spawn.
-            url = url_path_join(self.base_url, 'users', user.name)
+            url = url_path_join(self.base_url, 'user', user.name)
             self.redirect(url)
     
     @web.authenticated


Test patch:
diff --git a/jupyterhub/tests/test_pages.py b/jupyterhub/tests/test_pages.py
--- a/jupyterhub/tests/test_pages.py
+++ b/jupyterhub/tests/test_pages.py
@@ -1,5 +1,7 @@
 """Tests for HTML pages"""
 
+from urllib.parse import urlparse
+
 import requests
 
 from ..utils import url_path_join as ujoin
@@ -7,6 +9,7 @@
 
 import mock
 from .mocking import FormSpawner
+from .test_api import api_request
 
 
 def get_page(path, app, **kw):
@@ -59,10 +62,34 @@ def test_admin(app):
     r.raise_for_status()
     assert r.url.endswith('/admin')
 
-def test_spawn_redirect(app):
-    cookies = app.login_user('wash')
+def test_spawn_redirect(app, io_loop):
+    name = 'wash'
+    cookies = app.login_user(name)
+    u = app.users[orm.User.find(app.db, name)]
+    
+    # ensure wash's server isn't running:
+    r = api_request(app, 'users', name, 'server', method='delete', cookies=cookies)
+    r.raise_for_status()
+    status = io_loop.run_sync(u.spawner.poll)
+    assert status is not None
+    
+    # test spawn page when no server is running
     r = get_page('spawn', app, cookies=cookies)
-    assert r.url.endswith('/wash')
+    r.raise_for_status()
+    print(urlparse(r.url))
+    path = urlparse(r.url).path
+    assert path == '/user/%s' % name
+    
+    # should have started server
+    status = io_loop.run_sync(u.spawner.poll)
+    assert status is None
+    
+    # test spawn page when server is already running (just redirect)
+    r = get_page('spawn', app, cookies=cookies)
+    r.raise_for_status()
+    print(urlparse(r.url))
+    path = urlparse(r.url).path
+    assert path == '/user/%s' % name
 
 def test_spawn_page(app):
     with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):