146
assert "tun/m/assignments" in mock_session.request.call_args.args[1]
147
148
149
-def test_client_keep_alive_assignment_handles_empty_array_response(
150
- client, mock_session
151
-):
152
- """KeepAliveAssignment returns `[]` on success (grpc-web protojson). When the
153
- caller passes no `schema=`, _issue_request must not try to validate the
154
- body — otherwise it raises pydantic ValidationError on the empty list.
155
- Regression: discovered live 2026-04-30."""
149
+def test_client_keep_alive_assignment_handles_empty_response(client, mock_session):
150
+ """The tunnel keep-alive ping returns an empty body. With no `schema=`,
151
+ _issue_request must short-circuit and not attempt to parse it."""
152
resp = MagicMock()
153
resp.ok = True
158
- resp.text = "[]"
154
+ resp.text = ""
155
mock_session.request.return_value = resp
156
157
# Should NOT raise.
160
161
162
def test_client_keep_alive_assignment_request_shape(client, mock_session):
167
- """The RuntimeService rejects the request with HTTP 400 unless
168
- `X-Goog-Api-Client` contains `grpc-web`. This test pins the wire format
169
- that talks to colab.pa.googleapis.com.
163
+ """Keep-alive is a Tunnel Frontend (TFE) HTTP ping, NOT the
164
+ `colab.pa.googleapis.com` RuntimeService RPC.
165
+
166
+ Background: the RuntimeService RPC requires the caller to be a
167
+ serviceusage consumer of Colab's internal project (1014160490159), which
168
+ no ordinary user account is. That path returned HTTP 403
169
+ USER_PROJECT_DENIED for every external user (issue #14). The official
170
+ Colab clients (and the colab-vscode extension) keep assignments alive via
171
+ a TFE-intercepted GET that only needs the user's own Gaia bearer token:
172
+
173
+ GET https://colab.research.google.com/tun/m/<endpoint>/keep-alive/
174
+ X-Colab-Tunnel: Google
175
+
176
+ TFE records LastActiveTime before forwarding, so the request keeps the VM
177
+ from being idle-pruned. This test pins that wire format.
178
"""
179
resp = MagicMock()
180
resp.ok = True
187
call = mock_session.request.call_args
188
method, url = call.args[0], call.args[1]
189
headers = call.kwargs["headers"]
182
- body = call.kwargs["json"]
190
184
- assert method == "POST"
185
- assert url.endswith(
186
- "/$rpc/google.internal.colab.v1.RuntimeService/KeepAliveAssignment"
187
- )
188
- # Positional protojson encoding: a single-element array with the endpoint.
189
- assert body == ["m-s-test-endpoint"]
190
- assert headers["Content-Type"] == "application/json+protobuf"
191
- assert "x-goog-api-key" in headers
192
- assert headers["x-user-agent"] == "grpc-web-javascript/0.1"
193
- # Critical: server requires `grpc-web` substring in this header.
194
- assert "grpc-web" in headers["x-goog-api-client"]
195
- # Critical: pin consumer project to Colab's, otherwise ADC user creds
196
- # (which carry their own gcloud quota project) trigger HTTP 400
197
- # CONSUMER_INVALID. Verified empirically 2026-04-30.
198
- assert headers["x-goog-user-project"] == "1014160490159"
191
+ assert method == "GET"
192
+ # TFE tunnel keep-alive path on the session backend host.
193
+ assert url.endswith("/tun/m/m-s-test-endpoint/keep-alive/")
194
+ assert "colab.research.google.com" in url
195
+ # The request must be resolved through the Colab tunnel; without this
196
+ # header the front-door rejects the request with HTTP 400.
197
+ assert headers["X-Colab-Tunnel"] == "Google"
198
+ # Must NOT hit the RuntimeService / pa.googleapis.com path anymore.
199
+ assert "pa.googleapis.com" not in url
200
+ assert "KeepAliveAssignment" not in url
201
+ # No fire-and-forget JSON body; this is a plain GET.
202
+ assert "json" not in call.kwargs
203
+ # A short timeout is supplied so the daemon stays responsive on its cadence.
204
+ assert call.kwargs.get("timeout") is not None
205
+
206
+
207
+def test_client_keep_alive_assignment_treats_read_timeout_as_success(
208
+ client, mock_session
209
+):
210
+ """TFE records activity as soon as the request arrives, then forwards to a
211
+ VM that may not respond — so the request commonly read-times-out even
212
+ though the keep-alive succeeded. A ReadTimeout must NOT propagate as an
213
+ error (otherwise the daemon would log spurious keep_alive_error events)."""
214
+ import requests
215
+
216
+ mock_session.request.side_effect = requests.exceptions.ReadTimeout("timed out")
217
+
218
+ # Should NOT raise.
219
+ result = client.keep_alive_assignment("m-s-test-endpoint")
220
+ assert result is None
221
+
222
+
223
+def test_client_keep_alive_assignment_propagates_http_error(client, mock_session):
224
+ """A genuine HTTP error (e.g. 404 for a deleted assignment) must still
225
+ surface so the daemon can react (e.g. stop after consecutive 4xx)."""
226
+ from colab_cli.client import ColabRequestError
227
+
228
+ resp = MagicMock()
229
+ resp.ok = False
230
+ resp.status_code = 404
231
+ resp.reason = "Not Found"
232
+ resp.text = "gone"
233
+ mock_session.request.return_value = resp
234
+
235
+ with pytest.raises(ColabRequestError):
236
+ client.keep_alive_assignment("m-s-test-endpoint")