| ... | @@ -7,12 +7,14 @@ import * as Duration from "effect/Duration"; | ... | @@ -7,12 +7,14 @@ import * as Duration from "effect/Duration"; |
| 7 | import * as Effect from "effect/Effect"; | 7 | import * as Effect from "effect/Effect"; |
| 8 | import * as Exit from "effect/Exit"; | 8 | import * as Exit from "effect/Exit"; |
| 9 | import * as Layer from "effect/Layer"; | 9 | import * as Layer from "effect/Layer"; |
| | 10 | import * as Option from "effect/Option"; |
| 10 | import * as Queue from "effect/Queue"; | 11 | import * as Queue from "effect/Queue"; |
| 11 | import * as Ref from "effect/Ref"; | 12 | import * as Ref from "effect/Ref"; |
| 12 | import * as Result from "effect/Result"; | 13 | import * as Result from "effect/Result"; |
| 13 | import * as Semaphore from "effect/Semaphore"; | 14 | import * as Semaphore from "effect/Semaphore"; |
| 14 | import * as Scope from "effect/Scope"; | 15 | import * as Scope from "effect/Scope"; |
| 15 | import * as Stream from "effect/Stream"; | 16 | import * as Stream from "effect/Stream"; |
| | 17 | import { HttpClient } from "effect/http"; |
| 16 | import * as ChildProcess from "effect/process/ChildProcess"; | 18 | import * as ChildProcess from "effect/process/ChildProcess"; |
| 17 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; | 19 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 18 | | 20 | |
| ... | @@ -63,6 +65,8 @@ interface ActiveConnector { | ... | @@ -63,6 +65,8 @@ interface ActiveConnector { |
| 63 | readonly startedAtMillis: number; | 65 | readonly startedAtMillis: number; |
| 64 | /** Completes when the connector first registers a tunnel connection. */ | 66 | /** Completes when the connector first registers a tunnel connection. */ |
| 65 | readonly registered: Deferred.Deferred<void>; | 67 | readonly registered: Deferred.Deferred<void>; |
| | 68 | /** Completes with the loopback address of the connector's metrics server. */ |
| | 69 | readonly metricsAddress: Deferred.Deferred<string>; |
| 66 | } | 70 | } |
| 67 | | 71 | |
| 68 | // A connector that exits before running this long is treated as part of a | 72 | // A connector that exits before running this long is treated as part of a |
| ... | @@ -80,6 +84,18 @@ const TUNNEL_AUTHORIZATION_FAILURES_BEFORE_RECOVERY = 4; | ... | @@ -80,6 +84,18 @@ const TUNNEL_AUTHORIZATION_FAILURES_BEFORE_RECOVERY = 4; |
| 80 | // Ask for recovery after this long, and again at this interval while it stays | 84 | // Ask for recovery after this long, and again at this interval while it stays |
| 81 | // unconnected; the relay hands back the same tunnel when it is still live. | 85 | // unconnected; the relay hands back the same tunnel when it is still live. |
| 82 | const CONNECTOR_REGISTRATION_TIMEOUT = Duration.minutes(3); | 86 | const CONNECTOR_REGISTRATION_TIMEOUT = Duration.minutes(3); |
| | 87 | // cloudflared resolves the edge only at startup, so after a network change it |
| | 88 | // can stay alive with zero connections, dialing addresses that no longer route. |
| | 89 | // Its /ready endpoint answers 503 then. After this many 503s in a row (about |
| | 90 | // three minutes), the connector is restarted. Each restart that is never |
| | 91 | // followed by a ready answer doubles the count, up to about thirty minutes, so |
| | 92 | // a host that is really offline does not restart every few minutes. |
| | 93 | const CONNECTOR_READINESS_INTERVAL = Duration.seconds(30); |
| | 94 | const CONNECTOR_READINESS_REQUEST_TIMEOUT = Duration.seconds(5); |
| | 95 | const CONNECTOR_NOT_READY_CHECKS_BEFORE_RESTART = 6; |
| | 96 | const CONNECTOR_NOT_READY_CHECKS_MAX = 60; |
| | 97 | // The connector binds its metrics server to a random loopback port and logs it. |
| | 98 | const RELAY_CLIENT_METRICS_LINE = /\bStarting metrics server on (127\.0\.0\.1:\d+)\/metrics\b/u; |
| 83 | // A failed background install of the pinned relay client is retried no sooner | 99 | // A failed background install of the pinned relay client is retried no sooner |
| 84 | // than this, so a crash-looping connector or an offline host does not redownload | 100 | // than this, so a crash-looping connector or an offline host does not redownload |
| 85 | // on every reconcile. | 101 | // on every reconcile. |
| ... | @@ -162,6 +178,7 @@ const stopConnector = (connector: ActiveConnector | null) => | ... | @@ -162,6 +178,7 @@ const stopConnector = (connector: ActiveConnector | null) => |
| 162 | export const make = Effect.gen(function* () { | 178 | export const make = Effect.gen(function* () { |
| 163 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 179 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 164 | const relayClient = yield* RelayClient.RelayClient; | 180 | const relayClient = yield* RelayClient.RelayClient; |
| | 181 | const httpClient = HttpClient.withScope(yield* HttpClient.HttpClient); |
| 165 | const activeRef = yield* Ref.make<ActiveConnector | null>(null); | 182 | const activeRef = yield* Ref.make<ActiveConnector | null>(null); |
| 166 | const desiredConfigRef = yield* Ref.make<RelayManagedEndpointRuntimeConfig | null>(null); | 183 | const desiredConfigRef = yield* Ref.make<RelayManagedEndpointRuntimeConfig | null>(null); |
| 167 | const recoveryRequests = yield* Queue.sliding<RelayManagedEndpointRuntimeConfig>(1); | 184 | const recoveryRequests = yield* Queue.sliding<RelayManagedEndpointRuntimeConfig>(1); |
| ... | @@ -174,6 +191,7 @@ export const make = Effect.gen(function* () { | ... | @@ -174,6 +191,7 @@ export const make = Effect.gen(function* () { |
| 174 | const lastInstallFailureAtRef = yield* Ref.make<number | null>(null); | 191 | const lastInstallFailureAtRef = yield* Ref.make<number | null>(null); |
| 175 | const prunedRef = yield* Ref.make(false); | 192 | const prunedRef = yield* Ref.make(false); |
| 176 | const retryLoopRunningRef = yield* Ref.make(false); | 193 | const retryLoopRunningRef = yield* Ref.make(false); |
| | 194 | const notReadyChecksBeforeRestartRef = yield* Ref.make(CONNECTOR_NOT_READY_CHECKS_BEFORE_RESTART); |
| 177 | let reconcileConfig: CloudManagedEndpointRuntime["Service"]["applyConfig"]; | 195 | let reconcileConfig: CloudManagedEndpointRuntime["Service"]["applyConfig"]; |
| 178 | | 196 | |
| 179 | const stopActive = Effect.gen(function* () { | 197 | const stopActive = Effect.gen(function* () { |
| ... | @@ -393,6 +411,48 @@ export const make = Effect.gen(function* () { | ... | @@ -393,6 +411,48 @@ export const make = Effect.gen(function* () { |
| 393 | return false; | 411 | return false; |
| 394 | }).pipe(Effect.repeat({ until: (connected) => connected })); | 412 | }).pipe(Effect.repeat({ until: (connected) => connected })); |
| 395 | | 413 | |
| | 414 | // Kills a connector whose /ready keeps answering 503, so the supervisor |
| | 415 | // restarts it like any exited connector. Only an explicit 503 counts: a failed |
| | 416 | // request says nothing about the tunnel. A connector that never logs its |
| | 417 | // metrics address is not polled. |
| | 418 | const watchConnectorReadiness = (connector: ActiveConnector) => |
| | 419 | Effect.gen(function* () { |
| | 420 | const readyUrl = `http://${yield* Deferred.await(connector.metricsAddress)}/ready`; |
| | 421 | let notReadyChecks = 0; |
| | 422 | while (true) { |
| | 423 | yield* Effect.sleep(CONNECTOR_READINESS_INTERVAL); |
| | 424 | const status = yield* httpClient.get(readyUrl).pipe( |
| | 425 | Effect.map((response) => response.status), |
| | 426 | Effect.scoped, |
| | 427 | Effect.withTracerEnabled(false), |
| | 428 | Effect.timeoutOption(CONNECTOR_READINESS_REQUEST_TIMEOUT), |
| | 429 | Effect.orElseSucceed(() => Option.none<number>()), |
| | 430 | ); |
| | 431 | if (Option.isNone(status)) continue; |
| | 432 | if (status.value === 200) { |
| | 433 | notReadyChecks = 0; |
| | 434 | yield* Ref.set(notReadyChecksBeforeRestartRef, CONNECTOR_NOT_READY_CHECKS_BEFORE_RESTART); |
| | 435 | continue; |
| | 436 | } |
| | 437 | if (status.value !== 503) continue; |
| | 438 | notReadyChecks += 1; |
| | 439 | const threshold = yield* Ref.get(notReadyChecksBeforeRestartRef); |
| | 440 | if (notReadyChecks < threshold) continue; |
| | 441 | notReadyChecks = 0; |
| | 442 | yield* Ref.set( |
| | 443 | notReadyChecksBeforeRestartRef, |
| | 444 | Math.min(threshold * 2, CONNECTOR_NOT_READY_CHECKS_MAX), |
| | 445 | ); |
| | 446 | yield* Effect.logWarning("Relay client has no tunnel connection; restarting it", { |
| | 447 | pid: Number(connector.child.pid), |
| | 448 | notReadyChecks: threshold, |
| | 449 | tunnelId: connector.config.tunnelId, |
| | 450 | tunnelName: connector.config.tunnelName, |
| | 451 | }); |
| | 452 | yield* connector.child.kill().pipe(Effect.ignore); |
| | 453 | } |
| | 454 | }); |
| | 455 | |
| 396 | const observeConnectorOutput = (connector: ActiveConnector) => { | 456 | const observeConnectorOutput = (connector: ActiveConnector) => { |
| 397 | let rejectedRegistrations = 0; | 457 | let rejectedRegistrations = 0; |
| 398 | | 458 | |
| ... | @@ -443,8 +503,14 @@ export const make = Effect.gen(function* () { | ... | @@ -443,8 +503,14 @@ export const make = Effect.gen(function* () { |
| 443 | } | 503 | } |
| 444 | } | 504 | } |
| 445 | return Effect.logWarning("Relay client reported a transport warning", attributes); | 505 | return Effect.logWarning("Relay client reported a transport warning", attributes); |
| 446 | case "debug": | 506 | case "debug": { |
| 447 | return Effect.logDebug("Relay client output", attributes); | 507 | const metricsAddress = RELAY_CLIENT_METRICS_LINE.exec(line)?.[1]; |
| | 508 | return metricsAddress |
| | 509 | ? Deferred.succeed(connector.metricsAddress, metricsAddress).pipe( |
| | 510 | Effect.andThen(Effect.logDebug("Relay client output", attributes)), |
| | 511 | ) |
| | 512 | : Effect.logDebug("Relay client output", attributes); |
| | 513 | } |
| 448 | } | 514 | } |
| 449 | }), | 515 | }), |
| 450 | Effect.catchCause((cause) => | 516 | Effect.catchCause((cause) => |
| ... | @@ -507,7 +573,17 @@ export const make = Effect.gen(function* () { | ... | @@ -507,7 +573,17 @@ export const make = Effect.gen(function* () { |
| 507 | .spawn( | 573 | .spawn( |
| 508 | ChildProcess.make( | 574 | ChildProcess.make( |
| 509 | executable.executablePath, | 575 | executable.executablePath, |
| 510 | ["tunnel", "--no-autoupdate", "--loglevel", "info", "--output", "default", "run"], | 576 | [ |
| | 577 | "tunnel", |
| | 578 | "--no-autoupdate", |
| | 579 | "--loglevel", |
| | 580 | "info", |
| | 581 | "--output", |
| | 582 | "default", |
| | 583 | "--metrics", |
| | 584 | "127.0.0.1:0", |
| | 585 | "run", |
| | 586 | ], |
| 511 | { | 587 | { |
| 512 | detached: false, | 588 | detached: false, |
| 513 | env: { | 589 | env: { |
| ... | @@ -563,11 +639,13 @@ export const make = Effect.gen(function* () { | ... | @@ -563,11 +639,13 @@ export const make = Effect.gen(function* () { |
| 563 | config, | 639 | config, |
| 564 | startedAtMillis: yield* Clock.currentTimeMillis, | 640 | startedAtMillis: yield* Clock.currentTimeMillis, |
| 565 | registered: yield* Deferred.make<void>(), | 641 | registered: yield* Deferred.make<void>(), |
| | 642 | metricsAddress: yield* Deferred.make<string>(), |
| 566 | } satisfies ActiveConnector; | 643 | } satisfies ActiveConnector; |
| 567 | yield* Ref.set(activeRef, connector); | 644 | yield* Ref.set(activeRef, connector); |
| 568 | yield* Effect.forkIn(observeConnectorOutput(connector), connectorScope); | 645 | yield* Effect.forkIn(observeConnectorOutput(connector), connectorScope); |
| 569 | yield* Effect.forkIn(superviseConnector(connector), connectorScope); | 646 | yield* Effect.forkIn(superviseConnector(connector), connectorScope); |
| 570 | yield* Effect.forkIn(watchConnectorRegistration(connector), connectorScope); | 647 | yield* Effect.forkIn(watchConnectorRegistration(connector), connectorScope); |
| | 648 | yield* Effect.forkIn(watchConnectorReadiness(connector), connectorScope); |
| 571 | return { | 649 | return { |
| 572 | status: "running", | 650 | status: "running", |
| 573 | providerKind: "cloudflare_tunnel", | 651 | providerKind: "cloudflare_tunnel", |