| ... | @@ -59,6 +59,7 @@ interface ActiveConnector { | ... | @@ -59,6 +59,7 @@ interface ActiveConnector { |
| 59 | readonly configKey: string; | 59 | readonly configKey: string; |
| 60 | readonly config: RelayManagedEndpointRuntimeConfig; | 60 | readonly config: RelayManagedEndpointRuntimeConfig; |
| 61 | readonly startedAtMillis: number; | 61 | readonly startedAtMillis: number; |
| | 62 | readonly connected: Ref.Ref<boolean>; |
| 62 | } | 63 | } |
| 63 | | 64 | |
| 64 | // A connector that exits before running this long is treated as part of a | 65 | // A connector that exits before running this long is treated as part of a |
| ... | @@ -71,6 +72,11 @@ const RELAY_RESTART_BACKOFF_BASE_MS = 1_000; | ... | @@ -71,6 +72,11 @@ const RELAY_RESTART_BACKOFF_BASE_MS = 1_000; |
| 71 | const RELAY_RESTART_BACKOFF_MAX_MS = 60_000; | 72 | const RELAY_RESTART_BACKOFF_MAX_MS = 60_000; |
| 72 | // Newly created tunnels can fail authorization briefly while Cloudflare propagates their token. | 73 | // Newly created tunnels can fail authorization briefly while Cloudflare propagates their token. |
| 73 | const TUNNEL_AUTHORIZATION_FAILURES_BEFORE_RECOVERY = 4; | 74 | const TUNNEL_AUTHORIZATION_FAILURES_BEFORE_RECOVERY = 4; |
| | 75 | // A connector that never registers a connection may hold a token for a tunnel |
| | 76 | // that no longer exists, rejected in wording the output check does not know. |
| | 77 | // Ask for recovery after this long, and again at this interval while it stays |
| | 78 | // unconnected; the relay hands back the same tunnel when it is still live. |
| | 79 | const CONNECTOR_REGISTRATION_TIMEOUT = Duration.minutes(3); |
| 74 | | 80 | |
| 75 | export function classifyRelayClientOutput(line: string): "connected" | "warning" | "debug" { | 81 | export function classifyRelayClientOutput(line: string): "connected" | "warning" | "debug" { |
| 76 | if (/\bRegistered tunnel connection\b/iu.test(line)) { | 82 | if (/\bRegistered tunnel connection\b/iu.test(line)) { |
| ... | @@ -84,14 +90,16 @@ export function classifyRelayClientOutput(line: string): "connected" | "warning" | ... | @@ -84,14 +90,16 @@ export function classifyRelayClientOutput(line: string): "connected" | "warning" |
| 84 | | 90 | |
| 85 | /** | 91 | /** |
| 86 | * Cloudflare's edge rejects a connector whose tunnel was deleted or whose | 92 | * Cloudflare's edge rejects a connector whose tunnel was deleted or whose |
| 87 | * token no longer matches. Current edge output is | 93 | * token no longer matches. The edge words this differently over time |
| 88 | * `error="Failed to get tunnel"` with no prefix; older edges prefixed the | 94 | * (`Failed to get tunnel`, `Tunnel not found`, ...), sometimes prefixed with |
| 89 | * same messages with `Unauthorized:`. Match both so recovery fires on either. | 95 | * `Unauthorized:`. Treat any `Unauthorized:` registration error as a rejection, |
| | 96 | * plus the unprefixed messages seen so far. Transient rejections while a new |
| | 97 | * tunnel's token propagates are absorbed by requiring several in a row. |
| 90 | */ | 98 | */ |
| 91 | export function isRejectedRelayClientTunnelOutput(line: string): boolean { | 99 | export function isRejectedRelayClientTunnelOutput(line: string): boolean { |
| 92 | return ( | 100 | return ( |
| 93 | /\bRegister tunnel error from server side\b/iu.test(line) && | 101 | /\bRegister tunnel error from server side\b/iu.test(line) && |
| 94 | /error="(?:Unauthorized:\s*)?(?:Failed to get tunnel|Record for tunnel not found|Invalid tunnel secret)"/iu.test( | 102 | /error="(?:Unauthorized:[^"]*|Failed to get tunnel|Tunnel not found|Record for tunnel not found|Invalid tunnel secret)"/iu.test( |
| 95 | line, | 103 | line, |
| 96 | ) | 104 | ) |
| 97 | ); | 105 | ); |
| ... | @@ -220,6 +228,24 @@ export const make = Effect.gen(function* () { | ... | @@ -220,6 +228,24 @@ export const make = Effect.gen(function* () { |
| 220 | Effect.catchCause((cause) => Effect.logWarning("Relay client supervisor failed", { cause })), | 228 | Effect.catchCause((cause) => Effect.logWarning("Relay client supervisor failed", { cause })), |
| 221 | ); | 229 | ); |
| 222 | | 230 | |
| | 231 | // Requests recovery while the connector has not registered a connection, |
| | 232 | // once per timeout, until it connects or is replaced. |
| | 233 | const watchConnectorRegistration = (connector: ActiveConnector) => |
| | 234 | Effect.gen(function* () { |
| | 235 | yield* Effect.sleep(CONNECTOR_REGISTRATION_TIMEOUT); |
| | 236 | if (yield* Ref.get(connector.connected)) return true; |
| | 237 | yield* Effect.logWarning( |
| | 238 | "Relay client has not registered a tunnel connection; requesting recovery", |
| | 239 | { |
| | 240 | pid: Number(connector.child.pid), |
| | 241 | tunnelId: connector.config.tunnelId, |
| | 242 | tunnelName: connector.config.tunnelName, |
| | 243 | }, |
| | 244 | ); |
| | 245 | yield* Queue.offer(recoveryRequests, connector.config); |
| | 246 | return false; |
| | 247 | }).pipe(Effect.repeat({ until: (connected) => connected })); |
| | 248 | |
| 223 | const observeConnectorOutput = (connector: ActiveConnector) => { | 249 | const observeConnectorOutput = (connector: ActiveConnector) => { |
| 224 | let rejectedRegistrations = 0; | 250 | let rejectedRegistrations = 0; |
| 225 | | 251 | |
| ... | @@ -239,7 +265,10 @@ export const make = Effect.gen(function* () { | ... | @@ -239,7 +265,10 @@ export const make = Effect.gen(function* () { |
| 239 | switch (classifyRelayClientOutput(line)) { | 265 | switch (classifyRelayClientOutput(line)) { |
| 240 | case "connected": | 266 | case "connected": |
| 241 | rejectedRegistrations = 0; | 267 | rejectedRegistrations = 0; |
| 242 | return Effect.logInfo("Relay client tunnel connection registered", attributes).pipe( | 268 | return Ref.set(connector.connected, true).pipe( |
| | 269 | Effect.andThen( |
| | 270 | Effect.logInfo("Relay client tunnel connection registered", attributes), |
| | 271 | ), |
| 243 | Effect.andThen(Queue.offer(tunnelConnections, undefined)), | 272 | Effect.andThen(Queue.offer(tunnelConnections, undefined)), |
| 244 | Effect.asVoid, | 273 | Effect.asVoid, |
| 245 | ); | 274 | ); |
| ... | @@ -370,10 +399,12 @@ export const make = Effect.gen(function* () { | ... | @@ -370,10 +399,12 @@ export const make = Effect.gen(function* () { |
| 370 | configKey: nextConfigKey, | 399 | configKey: nextConfigKey, |
| 371 | config, | 400 | config, |
| 372 | startedAtMillis: yield* Clock.currentTimeMillis, | 401 | startedAtMillis: yield* Clock.currentTimeMillis, |
| | 402 | connected: yield* Ref.make(false), |
| 373 | } satisfies ActiveConnector; | 403 | } satisfies ActiveConnector; |
| 374 | yield* Ref.set(activeRef, connector); | 404 | yield* Ref.set(activeRef, connector); |
| 375 | yield* Effect.forkIn(observeConnectorOutput(connector), connectorScope); | 405 | yield* Effect.forkIn(observeConnectorOutput(connector), connectorScope); |
| 376 | yield* Effect.forkIn(superviseConnector(connector), connectorScope); | 406 | yield* Effect.forkIn(superviseConnector(connector), connectorScope); |
| | 407 | yield* Effect.forkIn(watchConnectorRegistration(connector), connectorScope); |
| 377 | return { | 408 | return { |
| 378 | status: "running", | 409 | status: "running", |
| 379 | providerKind: "cloudflare_tunnel", | 410 | providerKind: "cloudflare_tunnel", |