Open History to see the last commit
1import {
2 t3ConnectTroubleshootingUrl,
3 type T3ConnectTroubleshootingSection,
4} from "@t3tools/contracts";
5import type { RelayEnvironmentStatusResponse } from "@t3tools/contracts/relay";
6import * as DateTime from "effect/DateTime";
7
8import type {
9 ConnectionAttemptError,
10 ConnectionFailureCause,
11 ConnectionHop,
12 SupervisorConnectionState,
13} from "./model.ts";
14
15export type ConnectionHopResult = "ok" | "failed" | "unchecked";
16
17export interface ConnectionDiagnosisHop {
18 readonly hop: ConnectionHop;
19 readonly label: string;
20 readonly result: ConnectionHopResult;
21}
22
23export interface ConnectionDiagnosis {
24 /** Names the part that failed, e.g. "Mac mini's tunnel is offline". */
25 readonly headline: string;
26 readonly hops: ReadonlyArray<ConnectionDiagnosisHop>;
27 readonly steps: ReadonlyArray<string>;
28 readonly facts: ReadonlyArray<readonly [label: string, value: string]>;
29 /** The troubleshooting guide section for this failure. Null for direct routes, except a pending T3 Connect sign-in. */
30 readonly moreInfoUrl: string | null;
31}
32
33export interface ConnectionDiagnosisInput {
34 readonly hostLabel: string;
35 readonly state: SupervisorConnectionState;
36 /** The environment's usual route, used when the failure does not name one. */
37 readonly via: "relay" | "direct";
38 /** The relay's last health check, which can name a cause before any connect attempt. */
39 readonly relayStatus?: Pick<
40 RelayEnvironmentStatusResponse,
41 "status" | "endpointFailure" | "endpointStatus" | "offlineReason"
42 > | null;
43 readonly clientVersion?: string | undefined;
44 readonly serverVersion?: string | null | undefined;
45}
46
47interface Located {
48 readonly hop: ConnectionHop;
49 readonly cause:
50 | ConnectionFailureCause
51 | "offline"
52 | "outdated"
53 | "released"
54 | "session_pending"
55 | null;
56 /** The failure named no hop, so the hops before this one were never probed. */
57 readonly guessed?: true;
58}
59
60const NOT_ANSWERING: ReadonlySet<ConnectionFailureCause> = new Set([
61 "origin_unreachable",
62 "invalid_response",
63]);
64
65function locate(input: ConnectionDiagnosisInput): Located | null {
66 const failure = input.state.lastFailure;
67 if (input.state.network === "offline" || input.state.phase === "offline") {
68 return { hop: "device", cause: "offline" };
69 }
70 if (failure?._tag === "ConnectionBlockedError" && failure.serverUpdateRequired) {
71 return { hop: "server", cause: "outdated" };
72 }
73 if (failure?.reason === "session-pending") {
74 return { hop: "device", cause: "session_pending" };
75 }
76 if (failure?.hop !== undefined) {
77 return { hop: failure.hop, cause: failure.failureCause ?? null };
78 }
79 if (failure !== null) {
80 const unreachable =
81 failure.reason === "network" ||
82 failure.reason === "timeout" ||
83 failure.reason === "transport";
84 const via = failure.via ?? input.via;
85 return {
86 hop: unreachable && via === "relay" ? "tunnel" : "server",
87 cause: null,
88 guessed: true,
89 };
90 }
91 const status = input.relayStatus;
92 if (status?.status !== "offline") return null;
93 if (status.offlineReason === "tunnel_released") return { hop: "tunnel", cause: "released" };
94 const cause = status.endpointFailure ?? null;
95 return { hop: cause !== null && NOT_ANSWERING.has(cause) ? "server" : "tunnel", cause };
96}
97
98function headline(host: string, located: Located, failure: ConnectionAttemptError | null): string {
99 switch (located.cause) {
100 case "offline":
101 return "This device is offline";
102 case "session_pending":
103 return "This device is still loading its T3 Connect sign-in";
104 case "approval_pending":
105 return `${host} is waiting for someone to approve this device`;
106 case "outdated":
107 return `${host} runs an older T3 Code`;
108 case "clock_skew":
109 return "This device's clock is off";
110 case "not_linked":
111 return `${host} is not linked to your T3 Connect account`;
112 case "relay_error":
113 return "T3 Connect returned an error";
114 case "tunnel_pending":
115 return `${host}'s tunnel is still being set up`;
116 case "tunnel_not_enabled":
117 return `T3 Connect is not turned on for ${host}`;
118 case "released":
119 case "tunnel_down":
120 return `${host}'s tunnel is offline`;
121 case "origin_unreachable":
122 return `Tunnel works, but T3 Code on ${host} did not answer`;
123 case "invalid_response":
124 return `T3 Code on ${host} sent an unexpected answer`;
125 case "dns":
126 return `${host}'s tunnel address did not resolve`;
127 case "tls":
128 return `Secure connection to ${host}'s tunnel failed`;
129 case "refused":
130 return `${host}'s tunnel refused the connection`;
131 case "timeout":
132 return `${host} did not answer in time`;
133 case "unknown":
134 case null:
135 break;
136 }
137 switch (located.hop) {
138 case "device":
139 return "This device could not prepare the connection";
140 case "relay":
141 return failure?.reason === "authentication"
142 ? "T3 Connect did not accept this device's sign-in"
143 : "This device can't reach T3 Connect";
144 case "tunnel":
145 // The relay already reached the host when this device fails on its own.
146 return failure?.reason === "network" ||
147 failure?.reason === "transport" ||
148 failure?.reason === "timeout"
149 ? `This device can't reach ${host}'s tunnel`
150 : `T3 Connect could not reach ${host}'s tunnel`;
151 case "server":
152 return failure?.reason === "authentication" || failure?.reason === "permission"
153 ? `T3 Code on ${host} did not accept this device`
154 : `T3 Code on ${host} did not answer`;
155 }
156}
157
158function steps(host: string, located: Located, failure: ConnectionAttemptError | null): string[] {
159 const checkHost = `On ${host}, check Settings > Sharing & access or run \`t3 connect status\`.`;
160 switch (located.cause) {
161 case "offline":
162 return ["Connect this device to the internet. T3 Code reconnects on its own."];
163 case "session_pending":
164 return [
165 "Wait a moment. T3 Code retries on its own.",
166 "If it keeps waiting, reload or restart T3 Code on this device.",
167 ];
168 case "approval_pending": {
169 const code =
170 failure?._tag === "ConnectionTransientError" ? failure.verificationCode : undefined;
171 const approve = code ? `approve the device showing ${code}` : "approve this device";
172 return [
173 `On a device already connected to ${host}, open Settings > Sharing & access and ${approve}.`,
174 `Or run \`t3 auth connect-device approve ${code ?? "<code>"}\` on ${host}.`,
175 ];
176 }
177 case "outdated":
178 return [`Update T3 Code on ${host}.`];
179 case "clock_skew":
180 return [`Turn on automatic date and time on this device and on ${host}, then retry.`];
181 case "not_linked":
182 return [
183 "Check that this device is signed in to the same T3 Connect account as the host.",
184 `On ${host}, open Settings > Sharing & access and turn T3 Connect on.`,
185 ];
186 case "tunnel_pending":
187 return ["Wait a minute. T3 Code retries on its own.", checkHost];
188 case "relay_error":
189 return [
190 "Wait a few minutes. T3 Code retries on its own.",
191 "If it keeps failing, copy the diagnostics and report it.",
192 ];
193 case "tunnel_not_enabled":
194 return [`On ${host}, open Settings > Sharing & access and turn T3 Connect on.`];
195 case "released":
196 case "tunnel_down":
197 return [
198 `Make sure ${host} is awake and T3 Code is running on it.`,
199 checkHost,
200 `A VPN or Tailscale exit node on ${host} can block the tunnel (port 7844). Turn it off or allow the tunnel.`,
201 ];
202 case "origin_unreachable":
203 case "invalid_response":
204 return [`Open or restart T3 Code on ${host}.`, checkHost];
205 // The relay saw these on its way to the host, so the fix is on the host.
206 case "dns":
207 case "tls":
208 case "refused":
209 case "timeout":
210 case "unknown":
211 return [`Make sure ${host} is awake and online.`, checkHost];
212 case null:
213 break;
214 }
215 switch (located.hop) {
216 case "device":
217 return [
218 "Restart T3 Code on this device.",
219 `If it keeps failing, remove ${host} from this device and add it again.`,
220 ];
221 case "relay":
222 return failure?.reason === "authentication"
223 ? ["Sign out of T3 Connect and sign in again."]
224 : [
225 "Check this device's internet connection.",
226 "A VPN, firewall, or ad blocker on this device can block T3 Connect.",
227 ];
228 case "tunnel":
229 return failure?.reason === "network" ||
230 failure?.reason === "transport" ||
231 failure?.reason === "timeout"
232 ? [
233 "A VPN, firewall, or DNS filter on this device can block the tunnel.",
234 "Try another network, or add a direct route such as a Tailscale address.",
235 ]
236 : [`Make sure ${host} is awake and online.`, checkHost];
237 case "server":
238 return [
239 `Make sure T3 Code is running on ${host}.`,
240 "Retry. If it keeps failing, copy the diagnostics.",
241 ];
242 }
243}
244
245function helpSection(
246 located: Located,
247 failure: ConnectionAttemptError | null,
248): T3ConnectTroubleshootingSection {
249 switch (located.cause) {
250 case "offline":
251 return "this-device-cant-reach-t3-connect";
252 case "outdated":
253 return "the-host-runs-an-older-t3-code";
254 case "clock_skew":
255 return "the-clock-is-off";
256 case "session_pending":
257 case "approval_pending":
258 case "not_linked":
259 case "tunnel_not_enabled":
260 return "sign-in-account-or-link-problems";
261 case "relay_error":
262 return "t3-connect-returned-an-error";
263 case "released":
264 case "tunnel_down":
265 case "tunnel_pending":
266 return "the-hosts-tunnel-is-offline";
267 case "origin_unreachable":
268 case "invalid_response":
269 return "the-tunnel-works-but-t3-code-does-not-answer";
270 case "dns":
271 case "tls":
272 case "refused":
273 case "timeout":
274 case "unknown":
275 return "the-t3-connect-address-does-not-resolve-or-answer";
276 case null:
277 break;
278 }
279 const onThisDevice =
280 failure?.reason === "network" ||
281 failure?.reason === "transport" ||
282 failure?.reason === "timeout";
283 switch (located.hop) {
284 case "device":
285 return "this-device-cant-reach-t3-connect";
286 case "relay":
287 return failure?.reason === "authentication"
288 ? "sign-in-account-or-link-problems"
289 : "this-device-cant-reach-t3-connect";
290 case "tunnel":
291 return onThisDevice ? "this-device-cant-reach-t3-connect" : "the-hosts-tunnel-is-offline";
292 case "server":
293 return failure?.reason === "authentication" || failure?.reason === "permission"
294 ? "sign-in-account-or-link-problems"
295 : "the-tunnel-works-but-t3-code-does-not-answer";
296 }
297}
298
299const HOP_ORDER = {
300 relay: ["device", "relay", "tunnel", "server"],
301 direct: ["device", "server"],
302} as const;
303
304function hopLabel(hop: ConnectionHop, host: string): string {
305 switch (hop) {
306 case "device":
307 return "This device";
308 case "relay":
309 return "T3 Connect";
310 case "tunnel":
311 return `${host} tunnel`;
312 case "server":
313 return `T3 Code on ${host}`;
314 }
315}
316
317/**
318 * Turns a connection state into what failed, in order, and what to try. Returns
319 * null when nothing has failed. Web and mobile render this same model.
320 */
321export function presentConnectionDiagnosis(
322 input: ConnectionDiagnosisInput,
323): ConnectionDiagnosis | null {
324 const located = locate(input);
325 if (located === null) return null;
326 const host = input.hostLabel;
327 const failure = input.state.lastFailure;
328 const relayPath =
329 located.hop === "relay" ||
330 located.hop === "tunnel" ||
331 (failure === null && input.relayStatus?.status === "offline") ||
332 (failure?.via ?? input.via) === "relay";
333 const order: ReadonlyArray<ConnectionHop> = HOP_ORDER[relayPath ? "relay" : "direct"];
334 const failedIndex = order.indexOf(located.hop);
335 const hops = order.map((hop, index): ConnectionDiagnosisHop => ({
336 hop,
337 label: hopLabel(hop, host),
338 result:
339 index === failedIndex
340 ? "failed"
341 : index < failedIndex && located.guessed !== true
342 ? "ok"
343 : "unchecked",
344 }));
345
346 // The relay health check only explains the state when no attempt has failed yet.
347 const httpStatus = failure !== null ? failure.httpStatus : input.relayStatus?.endpointStatus;
348 const facts: Array<readonly [string, string]> = [];
349 const add = (label: string, value: string | number | null | undefined) => {
350 if (value !== null && value !== undefined && value !== "") facts.push([label, String(value)]);
351 };
352 add("Cause", located.cause ?? failure?.reason);
353 add("HTTP status", httpStatus);
354 add("Attempts", input.state.attempt > 0 ? input.state.attempt : null);
355 add(
356 "Next retry",
357 input.state.retryAt === null
358 ? null
359 : DateTime.formatIso(DateTime.makeUnsafe(input.state.retryAt)),
360 );
361 add("Route", relayPath ? "T3 Connect" : "Direct");
362 add("App version", input.clientVersion);
363 add("Host version", input.serverVersion);
364 add("Error", failure?.detail);
365 add("Trace ID", failure?.traceId);
366
367 return {
368 headline: headline(host, located, failure),
369 hops,
370 steps: steps(host, located, failure),
371 facts,
372 moreInfoUrl:
373 relayPath || located.cause === "session_pending"
374 ? t3ConnectTroubleshootingUrl(helpSection(located, failure))
375 : null,
376 };
377}
378
379const HOP_MARK = { ok: "ok", failed: "FAILED", unchecked: "not checked" } as const;
380
381/** One plain-text block for bug reports. */
382export function connectionDiagnosisText(hostLabel: string, diagnosis: ConnectionDiagnosis): string {
383 return [
384 `T3 Code connection to ${hostLabel}: ${diagnosis.headline}`,
385 "",
386 ...diagnosis.hops.map((hop) => `${hop.label}: ${HOP_MARK[hop.result]}`),
387 "",
388 ...diagnosis.facts.map(([label, value]) => `${label}: ${value}`),
389 ...(diagnosis.moreInfoUrl ? ["", `More info: ${diagnosis.moreInfoUrl}`] : []),
390 ].join("\n");
391}