From 02e1abf638c15f5e72d456fb25a68c793273612b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 1 Nov 2026 06:37:52 +0000 Subject: [PATCH 1/2] test(server): reproduce #28531, listing waits out an asleep peer's addresses one by one Adds a failing test: a linked peer asleep at both of its loopback addresses keeps PeerLinks.list waiting past one 3 s probe budget, because statusOf probes the addresses in turn. The testkit's served peer gains an asleep switch whose descriptor requests go unanswered. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/peer/PeerLinks.test.ts | 37 +++++++++++++++++++++++ apps/server/src/peer/PeerLinks.testkit.ts | 16 ++++++++++ 2 files changed, 53 insertions(+) diff --git a/apps/server/src/peer/PeerLinks.test.ts b/apps/server/src/peer/PeerLinks.test.ts index f16a91f26c0ad..a2e685c17f3ca 100644 --- a/apps/server/src/peer/PeerLinks.test.ts +++ b/apps/server/src/peer/PeerLinks.test.ts @@ -2,10 +2,13 @@ import { NodeHttpServer } from "@effect/platform-node"; import { type ExecutionEnvironmentDescriptor, OrchestratorMcpFailure } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as SqlClient from "effect/sql/SqlClient"; @@ -339,6 +342,40 @@ it.effect("never sends a link's token to an address that stops answering as that ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); +it.effect("lists a peer asleep at every address as unreachable within one probe budget", () => + Effect.scoped( + Effect.gen(function* () { + const b = yield* serveB(box); + // Another address of the same machine, asleep too. + const wake = yield* Deferred.make(); + const elsewhere = yield* serveRaw(() => + Deferred.await(wake).pipe(Effect.as(HttpServerResponse.empty({ status: 503 }))), + ); + yield* Effect.addFinalizer(() => Deferred.succeed(wake, undefined)); + const a = yield* makeA(laptop); + const pairing = yield* b.auth.issuePairingCredential(); + yield* a.links.link({ + url: b.url, + alternateUrls: [elsewhere], + pairingCode: pairing.credential, + access: "auto", + }); + + yield* Ref.set(b.asleep, true); + const listing = yield* a.links.list.pipe(Effect.forkScoped); + yield* Queue.take(b.unanswered); + yield* TestClock.adjust(Duration.seconds(3)); + yield* Effect.yieldNow; + const listed = listing.pollUnsafe(); + expect( + listed === undefined + ? "still waiting on the asleep peer" + : Exit.map(listed, (links) => links.map((link) => link.status)), + ).toEqual(Exit.succeed(["unreachable"])); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + it.effect("reads one stored link without asking the peer", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/peer/PeerLinks.testkit.ts b/apps/server/src/peer/PeerLinks.testkit.ts index 0f7e39355b95d..c10e752644c3d 100644 --- a/apps/server/src/peer/PeerLinks.testkit.ts +++ b/apps/server/src/peer/PeerLinks.testkit.ts @@ -6,8 +6,10 @@ import { type ExecutionEnvironmentDescriptor, } from "@t3tools/contracts"; import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as FetchHttpClient from "effect/http/FetchHttpClient"; import * as HttpRouter from "effect/http/HttpRouter"; @@ -66,6 +68,9 @@ export const servePeer = ( const descriptor = yield* Ref.make(initial); const bearers = yield* Ref.make>([]); const probes = yield* Ref.make(0); + const asleep = yield* Ref.make(false); + const unanswered = yield* Queue.unbounded(); + const wake = yield* Deferred.make(); const layerEnvironment = Layer.succeed(ServerEnvironment.ServerEnvironment, { getEnvironmentId: Ref.get(descriptor).pipe(Effect.map((current) => current.environmentId)), getDescriptor: Ref.get(descriptor), @@ -86,6 +91,11 @@ export const servePeer = ( const request = yield* HttpServerRequest.HttpServerRequest; if (request.url.startsWith("/.well-known/t3/environment")) { yield* Ref.update(probes, (count) => count + 1); + if (yield* Ref.get(asleep)) { + yield* Queue.offer(unanswered, undefined); + yield* Deferred.await(wake); + return yield* Effect.die("the peer was closed while asleep"); + } } const authorization = request.headers.authorization; if (authorization !== undefined) { @@ -122,6 +132,8 @@ export const servePeer = ( Layer.fresh, Layer.build, ); + // An unanswered request would hold the server open past the test. + yield* Effect.addFinalizer(() => Deferred.succeed(wake, undefined)); const address = (yield* HttpServer.HttpServer).address; if (address._tag === "UnixPathAddress") return yield* Effect.die("expected a TCP address"); const url = `http://127.0.0.1:${address.port}`; @@ -135,6 +147,10 @@ export const servePeer = ( bearers, /** How often its descriptor was asked for, as a probe of whether it answers. */ probes, + /** While set, its descriptor never answers, as a machine that is asleep. */ + asleep, + /** One entry per descriptor request it left unanswered while asleep. */ + unanswered, /** The sessions linking created there, which its Connections lists. */ linkedSessions: auth .listSessions() From ae322e036470447c9743c9d7712cb3cff2d03c01 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 1 Nov 2026 06:41:33 +0000 Subject: [PATCH 2/2] fix(server): listing linked environments no longer waits out an asleep peer statusOf probed a link's addresses one after another, each bounded by probeTimeout (10 s off loopback), so one sleeping peer held every listing for 10 s per address. Listing now races a link's addresses and lists the peer reachable as soon as one answers as it, and gives up after 3 s, listing it unreachable. resolve, link and describe keep probeTimeout, so a real call to a waking machine still gets its 10 s. Fixes pingdotgg/t3code#28531 Co-Authored-By: Claude Opus 5.5 --- apps/server/src/peer/PeerLinks.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/apps/server/src/peer/PeerLinks.ts b/apps/server/src/peer/PeerLinks.ts index 11d058744084b..37da44963886a 100644 --- a/apps/server/src/peer/PeerLinks.ts +++ b/apps/server/src/peer/PeerLinks.ts @@ -42,6 +42,8 @@ const LINK_REDIRECT_URI = "http://127.0.0.1/t3code-peer-link"; */ const probeTimeout = (origin: string) => isLocalLoopbackHost(new URL(origin).hostname) ? Duration.seconds(3) : Duration.seconds(10); +/** How long a listing waits on a link's addresses; a real call still gives a waking peer `probeTimeout`. */ +const LIST_PROBE_TIMEOUT = Duration.seconds(3); /** A usable link: where to reach the peer and the token to send there. */ export interface ResolvedPeerLink { @@ -489,16 +491,18 @@ const make = Effect.gen(function* () { const statusOf = (link: PeerLink, now: DateTime.Utc) => DateTime.isLessThanOrEqualTo(link.expiresAt, now) ? Effect.succeed("expired") - : Effect.findFirst(link.urls, (url) => - describe(url).pipe( - Effect.map( - (found) => Option.isSome(found) && found.value.environmentId === link.environmentId, + : Effect.raceAll( + link.urls.map((url) => + describe(url).pipe( + Effect.filterOrFail( + (found) => Option.isSome(found) && found.value.environmentId === link.environmentId, + ), ), ), ).pipe( - Effect.map((answered): PeerLinkStatus => - Option.isSome(answered) ? "reachable" : "unreachable", - ), + Effect.timeout(LIST_PROBE_TIMEOUT), + Effect.as("reachable"), + Effect.orElseSucceed((): PeerLinkStatus => "unreachable"), ); const list: PeerLinks["Service"]["list"] = Effect.gen(function* () {