Repository navigation
fix(server): linking says why each address failed discovery #30884
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e231a1f
ebf8857
79c443d
d542dd8
e83ad95
9468e9f
76fe6eb
850e188
e523d54
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,14 +5,14 @@ import { | |
| EnvironmentHttpApi, | ||
| EnvironmentId, | ||
| type ExecutionEnvironmentCapabilities, | ||
| type ExecutionEnvironmentDescriptor, | ||
| MCP_PEER_LINK_SOFTWARE_ID, | ||
| type PeerLink, | ||
| type PeerLinkCreateInput, | ||
| PeerLinkError, | ||
| type PeerLinkSummary, | ||
| } from "@t3tools/contracts"; | ||
| import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; | ||
| import * as Cause from "effect/Cause"; | ||
| import * as Context from "effect/Context"; | ||
| import * as Digest from "effect/crypto/Digest"; | ||
| import * as SecureRandom from "effect/crypto/SecureRandom"; | ||
|
|
@@ -31,6 +31,7 @@ import * as Schema from "effect/Schema"; | |
| import * as SqlClient from "effect/sql/SqlClient"; | ||
| import * as FetchHttpClient from "effect/http/FetchHttpClient"; | ||
| import * as HttpClient from "effect/http/HttpClient"; | ||
| import * as HttpClientError from "effect/http/HttpClientError"; | ||
| import * as HttpApi from "effect/http-api/HttpApi"; | ||
| import * as HttpApiClient from "effect/http-api/HttpApiClient"; | ||
| import { isLocalLoopbackHost, isTailnetHost } from "@t3tools/shared/hostClassification"; | ||
|
|
@@ -176,6 +177,38 @@ const peerOrigin = (url: string) => { | |
| return Effect.succeed(parsed.origin); | ||
| }; | ||
|
|
||
| /** The system error under a failed connection, such as ECONNREFUSED or ENOTFOUND. */ | ||
| const connectionCode = (cause: unknown) => | ||
| [cause, Predicate.hasProperty(cause, "cause") ? cause.cause : undefined] | ||
| .map((error) => (Predicate.hasProperty(error, "code") ? error.code : undefined)) | ||
| .find(Predicate.isString); | ||
|
|
||
| /** Why `origin` did not answer as T3 Code, short enough for a toast. Never echoes a body. */ | ||
| const probeFailure = ( | ||
| origin: string, | ||
| error: HttpClientError.HttpClientError | Schema.SchemaError | Cause.TimeoutError, | ||
| timeout: Duration.Duration, | ||
| ) => { | ||
| const unreachable = (message: string) => new PeerLinkError({ reason: "unreachable", message }); | ||
| const certificate = certificateErrorCode(error); | ||
| if (certificate !== undefined) { | ||
| return unreachable(`${origin} presented a certificate this machine rejected (${certificate}).`); | ||
| } | ||
| if (Cause.isTimeoutError(error)) { | ||
| return unreachable(`${origin} did not answer within ${Duration.toSeconds(timeout)} seconds.`); | ||
| } | ||
| if (HttpClientError.isHttpClientError(error) && error.reason._tag === "TransportError") { | ||
| const code = connectionCode(error.reason.cause); | ||
| return unreachable( | ||
| code === undefined ? `${origin} did not answer.` : `${origin} did not answer (${code}).`, | ||
| ); | ||
| } | ||
| return new PeerLinkError({ | ||
| reason: "not_a_t3_environment", | ||
| message: `${origin} answered, but not as a T3 Code environment this version understands.`, | ||
| }); | ||
| }; | ||
|
|
||
|
Comment on lines
+180
to
+211
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do not build the transport message from
Print only literal text owned by the module. Use The table covers only the codes it lists. Codes outside the table lose their detail in the message. Decide which codes to cover before changing the message.
|
||
| /** Whether a host is this machine's own loopback. Tests serve their peers there, so they narrow it. */ | ||
| export const LoopbackHost = Context.Reference<(host: string) => boolean>( | ||
| "t3/peer/PeerLinks/LoopbackHost", | ||
|
|
@@ -217,41 +250,29 @@ const make = Effect.gen(function* () { | |
| Effect.provideService(HttpClient.HttpClient, peerHttpClient), | ||
| ); | ||
|
|
||
| /** | ||
| * The environment answering at `origin`, or nothing when it does not answer | ||
| * as T3 Code. A certificate this machine rejects fails instead, since | ||
| * the peer may well be there. | ||
| */ | ||
| const describe = (origin: string) => | ||
| clientFor(origin).pipe( | ||
| /** The environment answering at `origin`, or why nothing there answers as T3 Code. */ | ||
| const probe = (origin: string) => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The refactored reusable descriptor probe performs I/O without a named tracing boundary. Please define it with named Suggested fix: wrap the existing function body with Posted via Macroscope — Effect Service Conventions
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use a named Effect function for This reusable I/O operation returns an Effect from an arrow function. Convert it to As per coding guidelines, “Reusable functions use
|
||
| const timeout = probeTimeout(origin); | ||
| return clientFor(origin).pipe( | ||
| Effect.flatMap((client) => client.metadata.descriptor()), | ||
| // A brief network drop must not list a live peer as down; the timeout still bounds both tries. | ||
| Effect.retry({ | ||
| times: 1, | ||
| schedule: Schedule.spaced("1 second"), | ||
| while: (error) => certificateErrorCode(error) === undefined, | ||
| }), | ||
| Effect.timeout(probeTimeout(origin)), | ||
| Effect.asSome, | ||
| Effect.catch((error) => { | ||
| const code = certificateErrorCode(error); | ||
| return code === undefined | ||
| ? Effect.succeed(Option.none<ExecutionEnvironmentDescriptor>()) | ||
| : Effect.fail( | ||
| new PeerLinkError({ | ||
| reason: "unreachable", | ||
| message: `${origin} presented a certificate this machine rejected (${code}).`, | ||
| }), | ||
| ); | ||
| }), | ||
| Effect.timeout(timeout), | ||
| // No cause: a decode error can carry the response body to the client. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This translation drops the immediate underlying failure, contrary to the service-error convention. Please retain Suggested fix: preserve the cause on the internal structured failure and explicitly project only safe fields at the client boundary; this requires coordinated changes rather than a local suggestion. Posted via Macroscope — Effect Service Conventions |
||
| Effect.mapError((error) => probeFailure(origin, error, timeout)), | ||
| ); | ||
| }; | ||
|
|
||
| /** Whether `url` answers as `environmentId`, counting an untrusted certificate as no. */ | ||
| /** Whether `url` answers as `environmentId`, counting any failure as no. */ | ||
| const answersAs = Effect.fn("PeerLinks.answersAs")(function* ( | ||
| url: string, | ||
| environmentId: EnvironmentId, | ||
| ) { | ||
| const found = yield* describe(url).pipe(Effect.orElseSucceed(() => Option.none())); | ||
| const found = yield* probe(url).pipe(Effect.option); | ||
| return Option.filter(found, (peer) => peer.environmentId === environmentId); | ||
| }); | ||
|
|
||
|
|
@@ -404,7 +425,8 @@ const make = Effect.gen(function* () { | |
| /** | ||
| * The addresses that answer as `environmentId`, in the order given, with | ||
| * what the first one answered. Addresses this environment would not send | ||
| * credentials to are skipped; when every one is, the failure says why. | ||
| * credentials to are skipped; when none answers as it, the failure says why | ||
| * for each. | ||
| */ | ||
| const answeringAs = Effect.fn("PeerLinks.answeringAs")(function* ( | ||
| urls: ReadonlyArray<string>, | ||
|
|
@@ -423,28 +445,28 @@ const make = Effect.gen(function* () { | |
| message: `${origin} is this machine, not ${name}.`, | ||
| }); | ||
| } | ||
| return { origin, descriptor: yield* describe(origin) }; | ||
| const descriptor = yield* probe(origin); | ||
| if (descriptor.environmentId !== environmentId) { | ||
| return yield* new PeerLinkError({ | ||
| reason: "unreachable", | ||
| message: `${origin} answered as another environment, ${descriptor.label}.`, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bound the peer label in the mismatch message. If an address returns a valid descriptor with a very long As per coding guidelines, “Attributes and log annotations stay bounded: no raw payloads.”
|
||
| }); | ||
| } | ||
| return { origin, descriptor }; | ||
| }).pipe(Effect.result), | ||
| { concurrency: "unbounded" }, | ||
| ); | ||
| const matching = checked.flatMap((result) => | ||
| Result.isSuccess(result) && | ||
| Option.isSome(result.success.descriptor) && | ||
| result.success.descriptor.value.environmentId === environmentId | ||
| ? [{ origin: result.success.origin, descriptor: result.success.descriptor.value }] | ||
| : [], | ||
| Result.isSuccess(result) ? [result.success] : [], | ||
| ); | ||
| const first = matching[0]; | ||
| if (first === undefined) { | ||
| const refusals = checked.flatMap((result) => | ||
| const reasons = checked.flatMap((result) => | ||
| Result.isFailure(result) ? [result.failure.message] : [], | ||
| ); | ||
| return yield* new PeerLinkError({ | ||
| reason: "unreachable", | ||
| message: | ||
| refusals.length === checked.length | ||
| ? `This environment could not reach ${name}: ${refusals.join(" ")}` | ||
| : `This environment could not reach ${name} at ${urls.join(", ")}.`, | ||
| message: `This environment could not reach ${name}: ${reasons.join(" ")}`, | ||
| }); | ||
| } | ||
| // Each address is kept once, even when two spellings share an origin. | ||
|
|
@@ -456,14 +478,7 @@ const make = Effect.gen(function* () { | |
| const answeringAt = Effect.fn("PeerLinks.answeringAt")(function* (urls: ReadonlyArray<string>) { | ||
| const origins = yield* Effect.forEach(urls, peerOrigin); | ||
| const primary = origins[0]!; | ||
| const descriptor = yield* describe(primary); | ||
| if (Option.isNone(descriptor)) { | ||
| return yield* new PeerLinkError({ | ||
| reason: "not_a_t3_environment", | ||
| message: `Nothing at ${primary} answered as a T3 Code environment.`, | ||
| }); | ||
| } | ||
| return { primary, peer: descriptor.value, origins }; | ||
| return { primary, peer: yield* probe(primary), origins }; | ||
| }); | ||
|
|
||
| const link: PeerLinks["Service"]["link"] = Effect.fn("PeerLinks.link")(function* (input) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
unreachableonly forwards arguments into an error constructor, which the conventions explicitly disallow. Please constructPeerLinkErrordirectly in each failure branch; keepprobeFailureas the actual normalization mapper.Suggested fix: remove this helper and replace its three calls with
new PeerLinkError({ reason: "unreachable", message: ... }). This touches multiple hunks.Posted via Macroscope — Effect Service Conventions