fix: stop spurious 'drivemount failed' on OAuth waits over 10s (#11)

The drivefs handshake blocks the kernel on input_request while the user OAuths in their browser. jupyter_kernel_client's default 10s wall-clock timeout fires during this silent stretch, raising TimeoutError mid-flow even though the mount has actually succeeded server-side. Add an optional timeout= parameter to ColabRuntime.execute_code that is forwarded to both execute() and execute_interactive(). Pass timeout=600 (10 min) from the human-in-the-loop subcommands -- colab drivemount and colab auth -- via a shared INTERACTIVE_AUTOMATION_TIMEOUT_SEC constant. Non-interactive paths keep the upstream default since their continuous iopub traffic never trips the practical inactivity ceiling.

Tyler committed May 12, 2026 at 17:41 UTC 510115b0c3fff627e749144d84bad5fb8f748c57
5 files changed +117 -4
docs/04_automation_and_utility.md
+6
@@ -2,6 +2,7 @@
2 log:
3 2026-05-07: Added a developer-only `colab whoami` subcommand (hidden from `colab --help`). Mints an access token via the same `auth.get_credentials(...)` path the rest of the CLI uses (honoring the global `--auth=...` flag), refreshes the credentials, then queries `https://oauth2.googleapis.com/tokeninfo` to print the email, scopes, audience, and expiry of whatever the CLI is about to send. Built specifically to short-circuit the "why is my call to colab.pa.googleapis.com 403-ing" debugging loop — the answer is almost always "missing scope" or "wrong identity", both of which `whoami` makes immediately visible. Hidden via `app.command(hidden=True)`; reachable via `colab whoami` or `colab whoami --help`. Suppressed from the daily-update banner check (added to `_AUTO_UPDATE_SUPPRESSED` in `cli.py`) so the banner doesn't obscure the auth output.
4 2026-05-11: Removed the local-file update source (`update_file_path` setting and `_fetch_local` helper); `colab update` now consults PyPI only. Switched the default `update_url` to the canonical PyPI JSON API (`https://pypi.org/pypi/google-colab-cli/json`), which already exposes the `info.version` schema the auto-update subsystem expects. Re-added `colab update --install` as a public self-install path that runs `pip install -U google-colab-cli` against the current `sys.executable`; Linux-only (other platforms exit non-zero with an explanatory message), and a silent no-op when the cached `latest_version` is already at or below the current install.
5 +2026-05-12: Added an optional `timeout=` parameter to `ColabRuntime.execute_code` that flows through to both the `execute()` and `execute_interactive()` branches. `colab auth` and `colab drivemount` now pass `timeout=600` (10 min) via a shared `INTERACTIVE_AUTOMATION_TIMEOUT_SEC` constant in `commands/automation.py`. Background: `jupyter_kernel_client` defaults to a 10s wall-clock timeout that is consumed even when the kernel is idle waiting on `input_request`. With the drivefs hook intercepting that request and prompting the user to OAuth in their browser, any user that takes >10s to click through (essentially everyone) hit `TimeoutError` and saw "drivemount failed" even though the mount had actually succeeded server-side. The fix is scoped narrowly to the two human-in-the-loop subcommands; non-interactive paths (`colab exec`, `colab run`, `colab install`, `colab repl --pipe`, `colab console --pipe`) keep the upstream default since they receive continuous iopub traffic that resets the practical inactivity ceiling.
6 ---
7
8 # Design: Automation and Utility (`auth`, `install`, `log`, `pay`, `version`, `update`, `whoami`)
@@ -114,6 +115,11 @@ remediation guidance) rather than silently after ~1 minute via the daemon.
115 (`/tun/m/credentials-propagation/`), prompts the user with the Google OAuth
116 consent URL if needed, and dispatches the required `colab_reply` message to
117 the `stdin` channel to unlock the kernel thread.
118 +- **Timeout**: The kernel is silent (no iopub traffic) the entire time the
119 + user is OAuthing in their browser. To avoid the upstream 10s
120 + `jupyter_kernel_client` default raising `TimeoutError` mid-flow, this
121 + subcommand passes `timeout=INTERACTIVE_AUTOMATION_TIMEOUT_SEC` (600s) to
122 + `ColabRuntime.execute_code`. Same applies to `colab auth`.
123
124 ### 4. Logging and Notebook Capture (`colab log`)
125
src/colab_cli/commands/automation.py
+31 -4
@@ -26,8 +26,22 @@ from colab_cli.auth import get_credentials
26 from colab_cli.utils import get_status_code
27
28
29 +# Default execute() timeout for human-in-the-loop automations (auth /
30 +# drivemount). The kernel goes silent while the user completes a browser
31 +# OAuth flow, which can routinely take 30s+; the upstream 10s default
32 +# raises ``TimeoutError`` mid-flow even though the mount actually succeeds.
33 +# 10 minutes is long enough for any realistic interactive auth ceremony
34 +# without leaving CI hangs unbounded.
35 +INTERACTIVE_AUTOMATION_TIMEOUT_SEC = 600
36 +
37 +
38 def run_automation(
30 - name: str, op: str, code: str, allow_stdin: bool = False, path: str = None
39 + name: str,
40 + op: str,
41 + code: str,
42 + allow_stdin: bool = False,
43 + path: str = None,
44 + timeout: Optional[float] = None,
45 ):
46 from colab_cli.common import state
47
@@ -130,7 +144,7 @@ def run_automation(
144 else:
145 state.history.log_event(name, "automation", {"op": op, "code": code})
146
133 - outputs = runtime.execute_code(code, allow_stdin=allow_stdin)
147 + outputs = runtime.execute_code(code, allow_stdin=allow_stdin, timeout=timeout)
148 state.history.log_event(
149 name, "automation_result", {"op": op, "outputs": outputs}
150 )
@@ -166,7 +180,13 @@ def auth(
180 name = state.resolve_session(session)
181 code = "import os\nos.environ['USE_AUTH_EPHEM'] = '0'\nfrom google.colab import auth\nauth.authenticate_user()"
182 typer.echo(f"[colab] Starting Google Auth flow on {name}...")
169 - run_automation(name, "auth", code, allow_stdin=True)
183 + run_automation(
184 + name,
185 + "auth",
186 + code,
187 + allow_stdin=True,
188 + timeout=INTERACTIVE_AUTOMATION_TIMEOUT_SEC,
189 + )
190
191
192 def drivemount(
@@ -181,7 +201,14 @@ def drivemount(
201 name = state.resolve_session(session)
202 code = f"from google.colab import drive\ndrive.mount('{path}')"
203 typer.echo(f"[colab] Mounting Google Drive to '{path}' on {name}...")
184 - run_automation(name, "drivemount", code, allow_stdin=True, path=path)
204 + run_automation(
205 + name,
206 + "drivemount",
207 + code,
208 + allow_stdin=True,
209 + path=path,
210 + timeout=INTERACTIVE_AUTOMATION_TIMEOUT_SEC,
211 + )
212
213
214 def install(
src/colab_cli/runtime.py
+13
@@ -161,8 +161,21 @@ class ColabRuntime:
161 allow_stdin: bool = False,
162 stdin_hook: Any = None,
163 output_hook: Optional[Callable[[Dict[str, Any]], None]] = None,
164 + timeout: Optional[float] = None,
165 ) -> List[Dict[str, Any]]:
166 + # ``jupyter_kernel_client`` defaults ``timeout`` to ``REQUEST_TIMEOUT``
167 + # (10 seconds) on both ``execute`` and ``execute_interactive``. That
168 + # value is a wall-clock budget that shrinks every time the poll loop
169 + # iterates -- as long as iopub/stdin events arrive back-to-back the
170 + # call survives, but a single >10s quiet stretch (e.g. a kernel
171 + # blocked on ``input_request`` while the user OAuths in the browser)
172 + # will raise ``TimeoutError`` even though the underlying execution is
173 + # still healthy. Callers that know they need a longer ceiling can
174 + # pass ``timeout=`` here; otherwise we forward whatever the upstream
175 + # default is (currently 10s).
176 kwargs = {"allow_stdin": allow_stdin}
177 + if timeout is not None:
178 + kwargs["timeout"] = timeout
179
180 # Wrap stdin_hook to log inputs
181 original_stdin_hook = stdin_hook
tests/test_automation.py
+25
@@ -98,3 +98,28 @@ def test_cli_drivemount(mock_state, mock_runtime_class, mock_session):
98
99 assert "drive.mount('/foo/bar')" in called_code
100 assert mock_runtime.colab_request_hook is not None
101 + # Drivemount waits for the user to OAuth in their browser; the kernel
102 + # goes silent during that wait and the default 10s execute() timeout
103 + # would raise TimeoutError mid-flow. Insist on a generous timeout
104 + # (>= 5 minutes) being forwarded to runtime.execute_code.
105 + _, kwargs = mock_runtime.execute_code.call_args
106 + assert kwargs.get("timeout") is not None and kwargs["timeout"] >= 300
107 +
108 +
109 +@patch("colab_cli.commands.automation.ColabRuntime")
110 +@patch("colab_cli.common.state")
111 +def test_cli_auth_uses_long_timeout(mock_state, mock_runtime_class, mock_session):
112 + """`colab auth` walks the user through a paste-the-code flow that
113 + routinely takes >10s, so it must pass a generous timeout to
114 + runtime.execute_code or the call will TimeoutError mid-flow."""
115 + mock_state.store.get.return_value = mock_session
116 + mock_state.resolve_session.return_value = "test-session"
117 +
118 + mock_runtime = mock_runtime_class.return_value
119 + mock_runtime.execute_code.return_value = [{"text": "Authenticated"}]
120 +
121 + result = runner.invoke(app, ["auth", "-s", "test-session"])
122 + assert result.exit_code == 0
123 +
124 + _, kwargs = mock_runtime.execute_code.call_args
125 + assert kwargs.get("timeout") is not None and kwargs["timeout"] >= 300
tests/test_runtime.py
+42
@@ -77,6 +77,48 @@ def test_colab_runtime_execute_code():
77 }
78
79
80 +def test_colab_runtime_execute_code_default_no_timeout():
81 + """By default, execute_code should NOT pass a timeout (relies on jupyter
82 + kernel client default), preserving existing behavior for fast / streaming
83 + workloads."""
84 + runtime = ColabRuntime("http://url", "token123")
85 + mock_kc = MagicMock()
86 + runtime._kernel_client = mock_kc
87 +
88 + mock_kc.execute.return_value = {"outputs": []}
89 + runtime.execute_code("print(1)")
90 +
91 + _, kwargs = mock_kc.execute.call_args
92 + assert "timeout" not in kwargs
93 +
94 +
95 +def test_colab_runtime_execute_code_with_timeout():
96 + """When a timeout is supplied, it must be forwarded to kernel_client.execute."""
97 + runtime = ColabRuntime("http://url", "token123")
98 + mock_kc = MagicMock()
99 + runtime._kernel_client = mock_kc
100 +
101 + mock_kc.execute.return_value = {"outputs": []}
102 + runtime.execute_code("print(1)", timeout=600)
103 +
104 + _, kwargs = mock_kc.execute.call_args
105 + assert kwargs.get("timeout") == 600
106 +
107 +
108 +def test_colab_runtime_execute_interactive_with_timeout():
109 + """timeout must also be plumbed through the execute_interactive branch
110 + (used when an output_hook is supplied)."""
111 + runtime = ColabRuntime("http://url", "token123")
112 + mock_kc = MagicMock()
113 + runtime._kernel_client = mock_kc
114 +
115 + mock_kc.execute_interactive.return_value = {"content": {"status": "ok"}}
116 + runtime.execute_code("print(1)", output_hook=lambda o: None, timeout=600)
117 +
118 + _, kwargs = mock_kc.execute_interactive.call_args
119 + assert kwargs.get("timeout") == 600
120 +
121 +
122 def test_colab_runtime_stop():
123 runtime = ColabRuntime("http://url", "token123")
124 mock_kc = MagicMock()