Skip to content

[Feature]: Add durable Remote Link failure diagnostics and opt-in tunnel debug logging #7297

Open

Description

@arikon

Area

Multiple areas

What are you trying to accomplish?

Diagnose Remote Link failures after they have recovered. When a Child returns 503 {"error":"link tunnel unavailable"}, operators need durable diagnostics explaining the relay decision, SSH lifecycle and recovery attempts, plus an opt-in way to collect more detail for the next occurrence.

This proposal is based exclusively on the public main source at 6936d98 (2.88.0). It contains no private installation details or logs.

What prevents this today?

The public implementation has several observability gaps:

  • src/client/link-relay.ts:369-405: missing readiness and multiple fetch failures collapse into the same 503; the caught transport error and gate failure reason are not logged.
  • src/client/link-tunnel.ts:748-756: SSH stderr is classified internally, but the exit code, classification and useful sanitized diagnostic are not emitted as an event.
  • src/client/runtime.ts:289-293: the optional supervisor warn callback is not supplied, so its warning sites are silent in this runtime wiring.
  • src/link/ssh-argv.ts:86: SSH verbosity is fixed at LogLevel=ERROR.
  • src/cli/debug.ts: the debug scopes cover provider, usage, injection and Claude capture, not Remote Link. A client-role listener cannot enable serving-proxy debug through its local listener.

An instantaneous status cannot explain a failure that has already recovered. The generic 503 cannot distinguish a failed gate/ownership check, reconnect hold expiry, connection refusal/reset, header timeout or caller abort.

What should OpenCodex do?

  1. Persist low-volume structured events for tunnel transitions, process spawn/exit, failure classification, scheduled retries and successful recovery, for both Child- and Home-initiated links.
  2. Record a correlated diagnostic for every locally generated tunnel-unavailable 503: timestamp, opaque request/link identifiers, relay phase, tunnel state, stable reason code, safe transport error code, hold duration and next retry time where applicable. Preserve the existing wire response if needed.
  3. Offer a documented opt-in link debug scope or equivalent setting usable on the Child itself. Expose effective settings, log location, bounded retention/rotation, and a way to turn capture off. Include more detailed readiness/ownership/probe results and safely bounded SSH diagnostics when enabled.
  4. Keep credentials, authorization headers, prompts/request bodies and account data out of these logs. Sanitize and bound SSH text; avoid event flooding during long outages.
  5. Verify observable diagnostics for refusal, reset, hold expiry, SSH exits and recovery, including redaction and default-disabled verbose capture.

Example usage or interface

Illustrative proposed interface, not an existing command:

ocx debug link on
ocx debug link status
ocx debug link logs -f
ocx debug link off

Illustrative synthetic events, not production logs or a required schema/order:

link.relay_unavailable requestId=<opaque> linkId=<opaque> phase=connect reason=connection_refused tunnelState=reconnecting errorCode=ECONNREFUSED holdMs=15000
link.tunnel_exit linkId=<opaque> exitCode=255 failureClass=network
link.retry_scheduled linkId=<opaque> delayMs=60000
link.recovered linkId=<opaque>

Basic diagnostics should explain a rejected turn even with verbose capture disabled; verbose capture adds bounded detail.

Alternatives or workarounds

Inspect service output and current link status, or reproduce SSH manually with verbose output. These approaches do not necessarily correlate an earlier failed request with the supervised SSH process and its environment. The provider debug scope does not capture the relay's tunnel readiness decision.

Additional context

Related public recovery work: #6899 and #6915. This proposal concerns observability rather than changing retry policy. The Home-initiated recovery fix and the 250 ms post-exit output-drain fix make durable diagnostics useful for distinguishing recovery scenarios without assuming a historical cause.

Public source: https://github.com/lidge-jun/opencodex/tree/6936d9885d8a9bda9301737affcbaf1a4ff00e17

Checks

  • I searched existing issues and documentation.
  • This request describes a concrete OpenCodex workflow rather than merely naming a desired technology.
  • I removed secrets and personal data.

Activity

  1. arikon commented on Oct 18, 2026

    @arikon
    ContributorAuthor

    Additional results from the macOS Child after upgrading to 2.88.0. The link is Child-initiated (ssh -L); Home remains on 2.80.0. Personal identifiers, PIDs, addresses and raw headers are omitted.

    Observations

    • A failed request received HTTP 503 2.003492 seconds after request body compression completed in the Codex client log. The preceding request in the same conversation received 200, and the following request received 200 about six seconds after the failure. This measures client-observed elapsed time, not the server-side ownership lookup itself.
    • The proxy and supervised SSH process kept their original PIDs and start times. SSH remained established and owned the expected local listener when inspected; no Home restart was observed.
    • Among 92 first HTTP 503 responses following a logged body-compression event after the Child restart, 71 arrived within 1.95–2.25 seconds of that event. This is a timing sample, not 92 independently confirmed ownership timeouts.
    • Twelve later executions of the exact macOS listener-owner lsof command all found the expected SSH PID in approximately 0.19–0.20 seconds. These checks cannot establish its outcome during the earlier failure.
    • Current /v1/models requests returned 200; an intentionally invalid empty /v1/responses request reached validation and returned 400 without making a model call. Subsequent real Codex requests also received 200. No runtime, service or SSH restart was performed during diagnosis.

    Source correlation and uncertainty

    At main revision 6936d9885d8a9bda9301737affcbaf1a4ff00e17:

    An intermittent local ownership-lookup timeout is the leading hypothesis, not an established root cause. The repeated approximately two-second signature fits this path while the SSH process stays alive. OpenCodex does not log the failed lookup's duration, exit/output, error or timeout outcome, so the historical result and why it might have been delayed remain unknown.

    Other configured waits are weaker matches for this timing: reconnect hold 15 seconds, relay authentication 5 seconds, response headers 300 seconds; the 64-waiter cutoff is immediate.

    Please include a correlated ownership-check diagnostic distinguishing match, mismatch, lookup error and deadline expiry, with elapsed time and supervisor state. A stable failure reason such as listener_owner_lookup_timeout would let operators confirm or reject this hypothesis. This does not propose bypassing ownership validation or assume that increasing its deadline is the correct fix.

  2. Ingwannu commented on Oct 18, 2026

    @Ingwannu
    Owner

    The timing is useful evidence, but it still does not identify the historical failed lookup. I checked current dev 8640c2f30edb: port-reclaim.ts retains the 2,000 ms owner-lookup limit and the tunnel admission path consumes a boolean ownership result. A successful later lsof sample cannot prove the failed call matched or timed out.

    A focused first PR can preserve that admission result while adding a bounded typed diagnostic: match/mismatch/lookup-error/deadline, elapsed time, check generation and supervisor state, correlated to the relay refusal. Cover injected timeout, spawn failure, mismatched PID, stale completion and success; diagnostics must not change retry/admission policy or let stale proof satisfy a new request. Default output should be metadata only, deduplicated/rate-limited and bounded on disk; opt-in SSH verbosity needs a separate bounded/redacted sink, since raw stderr/argv can contain identities and destinations. Please keep deadline tuning and debug-mode UX out of the first evidence slice. No live tunnel restart or bypass is required to test this.

  3. added
    needs-design설계 필요. Implementation waits on a written design.
    on Oct 18, 2026
  4. arikon commented on Oct 19, 2026

    @arikon
    ContributorAuthor

    Follow-up: the generic 503 has appeared with both approximately two-second and five-second timing signatures. Timing alone cannot establish the historical internal failure category or why an operation was delayed. The earlier ownership-lookup hypothesis remains unconfirmed.

    The new result below is a controlled synthetic reproduction, independent of production infrastructure: the public five-second relay-auth deadline can produce the exact same generic 503 before any data request is dispatched. No raw production logs, incident statistics, deployment configuration, identifiers or credentials are included.

    Controlled reproduction of the five-second path

    Synthetic test against main revision 6936d9885d8a9bda9301737affcbaf1a4ff00e17 (2.88.0). With the checkout's dependencies installed, save this as repro-link-auth-timeout.ts in the repository root and run bun repro-link-auth-timeout.ts (approximately ten seconds total).

    The fake peer accepts the authentication request but deliberately never replies. It binds only a dynamically allocated loopback port and uses a synthetic key/identity. The injected positive gate isolates the real connection-bound transport from OS listener ownership checks; fetchImpl is not replaced. It does not modify a running tunnel or use production credentials.

    import { createServer } from 'node:http';
    import { fetchBoundLinkRelay } from './src/client/link-relay-transport.ts';
    import { relayLinkDataRequest } from './src/client/link-relay.ts';
    let authRequests = 0, dataRequests = 0;
    const server = createServer((req, res) => {
      if (req.url?.startsWith('/.well-known/opencodex/link-relay-auth')) {
        authRequests++;
        // Intentionally never respond to the synthetic authentication request.
      } else { dataRequests++; res.writeHead(400); res.end('{}'); }
    });
    await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
    const port = (server.address() as {port:number}).port;
    const target = {tunnelPort:port, admissionKey:'synthetic-not-a-real-key', linkId:'lnk_0123456789abcdef', apiKeyId:'synthetic'};
    try {
      let start = performance.now();
      try { await fetchBoundLinkRelay(target, `http://127.0.0.1:${port}/v1/responses`, {method:'POST',body:'{}'}); }
      catch(e:any) { console.log(JSON.stringify({probe:'transport',seconds:(performance.now()-start)/1000,name:e.name,message:e.message,code:e.code,causeName:e.cause?.name,authRequests,dataRequests})); }
      start=performance.now();
      const response=await relayLinkDataRequest(new Request('http://localhost/v1/responses',{method:'POST',body:'{}'}),target,{tunnel:{connected:()=>true,pending:()=>false,waitForConnected:async()=>false}});
      console.log(JSON.stringify({probe:'relay',seconds:(performance.now()-start)/1000,status:response.status,body:await response.json(),retryAfter:response.headers.get('retry-after'),authRequests,dataRequests}));
    } finally { server.closeAllConnections(); server.close(); }

    Observed output (the counters are cumulative across the two sequential probes):

    {"probe":"transport","seconds":5.006377042,"name":"AbortError","message":"The operation was aborted","code":"ABORT_ERR","causeName":"TimeoutError","authRequests":1,"dataRequests":0}
    {"probe":"relay","seconds":5.004834625,"status":503,"body":{"error":"link tunnel unavailable"},"retryAfter":"1","authRequests":2,"dataRequests":0}

    Thus the generic failure is reproducible after an accepted loopback connection, while the tunnel gate reports connected, before any data request reaches the peer. This proves the code path, not that the historical five-second failures necessarily took it.

    Source correlation / diagnostic scope

    At the same pinned revision:

    • relay-auth.ts:6 sets the authentication deadline to 5,000 ms.
    • link-relay-transport.ts:161–183 applies it to the auth probe and rethrows the resulting transport error. Deadline expiry here produces AbortError / ABORT_ERR with TimeoutError as its cause, rather than LinkRelayAuthenticationError.
    • link-relay.ts:392–405 therefore falls through to the generic link tunnel unavailable response, with no underlying-error diagnostic. The existing narrow refused-connection retry does not apply to this abort.

    For the requested bounded diagnostics: correlated refusal metadata should distinguish ownership lookup outcomes from relay-auth deadline expiry and other transport failures. A dedicated injected auth-timeout regression test should assert that no data request was sent and that diagnostic classification preserves the existing 503/admission/retry behavior. This is not a proposal to increase deadlines, bypass admission, or add automatic replay.

  5. github-actions commented on Oct 19, 2026

    @github-actions
    Contributor

    Automated translation bookkeeping — detected language: English.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestneeds-design설계 필요. Implementation waits on a written design.

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions