All Implemented Interfaces:
Runnable, LimitOutputStream.DoneCallback
Direct Known Subclasses:
I2PTunnelDCCClient.Runner, I2PTunnelHTTPClientRunner

public class I2PTunnelRunnerextends I2PAppThread implements LimitOutputStream.DoneCallback
Thread that forwards traffic between an I2PSocket and a TCP Socket.

I2PTunnelRunner implements the core bidirectional data forwarding between I2P and TCP network connections. Operates two StreamForwarders: the I2P to TCP direction runs inline and the TCP to I2P direction runs concurrently via a shared executor pool.

Connection Flow:

  1. Runner is created with connected I2PSocket and TCP Socket
  2. Initial data may be sent immediately via initialI2PData/initialSocketData
  3. Two StreamForwarders run for bidirectional streaming; TCP to I2P via executor pool
  4. Runner monitors both connections for errors or disconnection
  5. On completion or failure, callbacks may be invoked and sockets are closed

Keep-Alive Support: When keep-alive is enabled for either connection, the runner may skip spawning one direction of forwarding if no data is expected. This optimization is used for simple GET requests that don't require responses.

Thread Safety: This class uses locks (slock) to coordinate socket access and prevent concurrent writes from multiple threads. The finishLock ensures thread-safe state transitions.

See Also:
  • Field Details

    • _log

      protected final Log _log
      The runner's logger, from the global context's log manager.
    • __runnerId

      private static final AtomicLong __runnerId
      Counter numbering the tunnel runners.
    • _runnerId

      private final long _runnerId
      This runner's id, for the logs.
    • MAX_PACKET_SIZE

      static final int MAX_PACKET_SIZE
      Max bytes streamed in a packet - smaller ones might be filled up to this size. Larger ones are not split (at least not on Sun's impl of BufferedOutputStream), but that is the streaming api's job...
      See Also:
    • NETWORK_BUFFER_SIZE

      static final int NETWORK_BUFFER_SIZE
      Network buffer size for buffered streams.
      See Also:
    • MAX_EMPTY_RECONNECT_CYCLES

      static final int MAX_EMPTY_RECONNECT_CYCLES
      Max number of consecutive no-progress cycles (stalls) the outer empty-response / body-resume reconnect loop may invoke the callback for. The callback's own for-loop handles connect-failure backoff, but when the destination is reachable yet always sends zero bytes, each cycle succeeds on attempt 1 and the outer loop would spin forever. This is the baseline for entities under RETRY_RAMP_UNIT_BYTES; stallCycleLimit(long) ramps the cap by one cycle per 1MB of Content-Length so a large file can outlive proportionally more tunnel-pool churn. A cycle that makes any forward progress on the entity body resets this budget (see MAX_RESUME_CYCLES).
      See Also:
    • MAX_RESUME_CYCLES

      static final int MAX_RESUME_CYCLES
      Absolute safety cap on total body-resume cycles regardless of progress. Progress resets the stall budget (MAX_EMPTY_RECONNECT_CYCLES) so a slowly-advancing transfer is never abandoned mid-body for lack of progress, but a pathological trickle (one byte per cycle forever) must still terminate. Well above any legitimate need: a healthy resume completes in 1–2 cycles.
      See Also:
    • RETRY_RAMP_UNIT_BYTES

      static final long RETRY_RAMP_UNIT_BYTES
      Entity-size unit for retry-budget ramping: size-scaled limits gain one cycle (or connect attempt) per this many bytes of Content-Length. Entities smaller than this keep the baseline caps untouched.
      See Also:
    • SCALED_CAP_MULTIPLIER

      static final int SCALED_CAP_MULTIPLIER
      Multiplier applied to a baseline budget to cap the size-scaled ramps. The historical caps were 8x baseline under a 4MB ramp unit, i.e. 7x baseline of headroom. The 1MB unit is 4x finer, so preserving the same saturation sizes (112MB for the stall budget, 896MB for the total budget) needs 4 * 7 = 28x baseline of headroom, 29x baseline in total.
      See Also:
    • MAX_SCALED_STALL_CYCLES

      static final int MAX_SCALED_STALL_CYCLES
      Hard cap on the size-scaled stall budget (SCALED_CAP_MULTIPLIER times baseline), bounding worst-case browser wait when the destination is genuinely stuck.
      See Also:
    • MAX_SCALED_RESUME_CYCLES

      static final int MAX_SCALED_RESUME_CYCLES
      Hard cap on the size-scaled total cycle budget (SCALED_CAP_MULTIPLIER times baseline).
      See Also:
    • NO_DEADLINE

      static final long NO_DEADLINE
      Sentinel for "no wall-clock deadline", matching I2PTunnelClientBase.NO_DEADLINE.
      See Also:
    • RESUME_DEADLINE_MS

      static final long RESUME_DEADLINE_MS
      Wall-clock ceiling on one entity's resume/reconnect sequence (ms), independent of Content-Length.

      The cycle caps in stallCycleLimit(long) and totalCycleLimit(long) bound attempt count, but each attempt may block for a full connect timeout, so a large entity's 29x-scaled budget can keep a runner busy for hours against a destination that is answering but never finishing. This deadline is the only bound expressed in the unit the browser actually waits in.

      Set well above what the baseline caps can consume (4 empty-reconnect cycles, each bounded by a connect timeout) so ordinary small-entity reconnects are never cut short; it only bounds the size-scaled ramp.

      See Also:
    • RETRY_RAMP_MAX_BYTES

      static final long RETRY_RAMP_MAX_BYTES
      Largest Content-Length that may enlarge the retry ramp (bytes).

      The ramp exists because a genuinely long transfer survives proportionally more tunnel-pool churn than a small file. A Content-Length beyond this is not a long transfer, it is an upstream claim no honest peer makes, and it must not be able to buy extra retry budget: the cap is already saturated long before this size, so clamping the input costs nothing real and denies a hostile or broken destination the largest rung of the ladder.

      See Also:
    • EMPTY_CYCLE_BASE_DELAY_MS

      static final long EMPTY_CYCLE_BASE_DELAY_MS
      Base delay before empty-retry cycle N+1 (ms). Combined with exponential growth this keeps successful-connect-but-empty retries from spinning back-to-back and stampeding the remote SYN-burst gate.
      See Also:
    • EMPTY_CYCLE_MAX_DELAY_MS

      static final long EMPTY_CYCLE_MAX_DELAY_MS
      Cap for empty-retry inter-cycle delay (ms).
      See Also:
    • RACE_TOKENS_MAX

      static final int RACE_TOKENS_MAX
      Per-dest race token bucket capacity (SYNs worth of budget).
      See Also:
    • RACE_REFILL_PER_SEC

      static final int RACE_REFILL_PER_SEC
      Token refill rate: tokens per second, keyed per remote dest.
      See Also:
    • RACE_COST_TOKENS

      static final int RACE_COST_TOKENS
      Budget cost of one dual-race open (two SYNs).
      See Also:
    • SINGLE_COST_TOKENS

      static final int SINGLE_COST_TOKENS
      Budget cost of one single-socket open on the empty path.
      See Also:
    • RACE_STAGGER_MS

      static final long RACE_STAGGER_MS
      Stagger between the two race connects (ms) to avoid same-tick double SYN.
      See Also:
    • EMPTY_RACE_TIMEOUT_MS

      static final long EMPTY_RACE_TIMEOUT_MS
      Overall wait for a race winner's first response byte (ms).
      See Also:
    • BODY_STALL_READ_TIMEOUT_MS

      static final long BODY_STALL_READ_TIMEOUT_MS
      No-bytes read timeout (ms) once a response body has started streaming. Armed after the first body byte so a black-holed tunnel fails over to Range resume instead of pinning the runner for the full browser window.
      See Also:
    • INITIAL_RESPONSE_TIMEOUT_MS

      static final long INITIAL_RESPONSE_TIMEOUT_MS
      Deadline for the very first response byte after a request is written upstream (ms). Without it, a peer that accepts the connection but never sends a byte pins the runner forever: the body stall timeout is only armed after the first byte and streaming inactivity is disabled for HTTP tunnels, so nothing else bounds the wait. The anchor slides forward on every request-side write, so a slow POST upload never trips it; it is re-armed on each empty-response reconnect. 120s matches streaming's default inactivity window and stays well inside browser patience (~300s) across the empty-retry budget. Superseded permanently by the first response byte, which hands off to BODY_STALL_READ_TIMEOUT_MS.
      See Also:
    • INITIAL_WATCHDOG_POLL_MS

      static final long INITIAL_WATCHDOG_POLL_MS
      Watchdog poll interval (ms); SimpleTimer2 rejects periods under 5s.
      See Also:
    • initialResponseTimeoutMs

      static volatile long initialResponseTimeoutMs
      Effective first-response deadline (ms), read per watchdog tick. Non-positive disables the watchdog entirely. Test hook; do not modify in production.
    • s

      private final Socket s
      Plain TCP socket (local or remote endpoint).
    • i2ps

      private volatile I2PSocket i2ps
      I2P socket (the tunnel connection). Non-final so an "empty response" reconnect may replace it with a fresh connection while the local browser socket stays open. Only ever reassigned within run() when a I2PTunnelRunner.ReconnectCallback is installed and yields a new connection. Volatile so the initial-response watchdog on the timer thread sees the swap when it fires.
    • slock

      private final Object slock
      Synchronization lock for socket access.
    • finishLock

      private final Object finishLock
      Lock for the finish state.
    • finished

      private volatile boolean finished
      True once the session has finished.
    • initialI2PData

      private final byte[] initialI2PData
      Data to send over I2P before normal forwarding starts.
    • initialSocketData

      private final byte[] initialSocketData
      Data to send over TCP before normal forwarding starts.
    • startedOn

      private final long startedOn
      When runner started up.
    • sockList

      private final List<I2PSocket> sockList
      The I2P sockets this session opened.
    • onTimeout

      private final Runnable onTimeout
      If we die before receiving any data, run this job.
    • _onFail

      private final I2PTunnelRunner.FailCallback _onFail
      Callback run when the session fails.
    • _onSuccess

      private I2PTunnelRunner.SuccessCallback _onSuccess
      Callback run when the session succeeds.
    • _reconnectCallback

      private I2PTunnelRunner.ReconnectCallback _reconnectCallback
      Optional reconnect callback for the empty-response retry; null = never retry.
    • totalSent

      private volatile long totalSent
      Total bytes sent to I2P.
    • totalReceived

      private volatile long totalReceived
      Total bytes received from I2P.
    • _noDataHandled

      private boolean _noDataHandled
      Prevent the no-data failure callback from firing more than once across the synchronous completion block and the exception/finally paths.
    • _lastRequestWriteMs

      private volatile long _lastRequestWriteMs
      Sliding anchor: epoch-ms of the most recent request-side write upstream. Refreshed by the toI2P forwarder so a slow upload never trips the initial-response deadline; reset on each empty-response re-send.
    • _firstByteMs

      private volatile long _firstByteMs
      Epoch-ms of the first response byte, 0 until one arrives; retires the initial-response watchdog permanently.
    • _initialDeadlineFired

      private volatile boolean _initialDeadlineFired
      One-shot latch per request attempt so a fired deadline does not warn and close again every watchdog tick; cleared when the anchor slides or a re-send starts a new attempt.
    • _initialWatchdogOn

      private volatile boolean _initialWatchdogOn
      Watchdog lifecycle: set on schedule, cleared on cancel, read by the timer thread so a tick racing run()'s finally cannot re-arm.
    • _initialWatchdog

      private volatile SimpleTimer2.TimedEvent _initialWatchdog
      Watchdog for the initial connection, kept for cancelling.
    • _keepAliveI2P

      protected volatile boolean _keepAliveI2P
      Keep I2P socket alive after data transfer.
    • _keepAliveSocket

      protected volatile boolean _keepAliveSocket
      Keep local socket alive after data transfer.
    • _runnerExecutor

      private volatile Executor _runnerExecutor
      Executor for submitting tasks; null = fallback to new Thread.
    • toI2P

      private volatile I2PTunnelRunner.StreamForwarder toI2P
      Forwarder copying data to I2P.
    • fromI2P

      private volatile I2PTunnelRunner.StreamForwarder fromI2P
      Forwarder copying data from I2P.
    • GET

      private static final byte[] GET
      GET request prefix for proxy mode.
    • POST

      private static final byte[] POST
      POST request prefix for proxy mode.
    • PUT

      private static final byte[] PUT
      PUT request prefix for proxy mode.
  • Constructor Details

    • I2PTunnelRunner

      public I2PTunnelRunner(Socket s, I2PSocket i2ps, Object slock, byte[] initialI2PData, byte[] initialSocketData, List<I2PSocket> sockList, I2PTunnelRunner.FailCallback onFail)
      Recommended new constructor. Does NOT start itself. Caller must call start().
      Parameters:
      s - the local socket, non-null
      i2ps - the I2P socket carrying the tunnel, non-null
      slock - the socket lock, non-null
      initialI2PData - may be null
      initialSocketData - may be null
      sockList - may be null. Caller must add i2ps to the list! It will be removed here on completion. Will synchronize on slock when removing.
      onFail - May be null. If non-null and no data (except initial data) was received, it will be run before closing s.
    • I2PTunnelRunner

      public I2PTunnelRunner(Socket s, I2PSocket i2ps, Object slock, byte[] initialI2PData, byte[] initialSocketData, List<I2PSocket> sockList, I2PTunnelRunner.FailCallback onFail, boolean keepAliveI2P, boolean keepAliveSocket)
      With keepAlive args. Does NOT start itself. Caller must call start().
      Parameters:
      s - the local socket, non-null
      i2ps - the I2P socket carrying the tunnel, non-null
      slock - the socket lock, non-null
      initialI2PData - may be null
      initialSocketData - may be null
      sockList - may be null. Caller must add i2ps to the list! It will be removed here on completion. Will synchronize on slock when removing.
      onFail - May be null. If non-null and no data (except initial data) was received, it will be run before closing s.
      keepAliveI2P - Do not close the I2P socket when done.
      keepAliveSocket - Do not close the local socket when done. For client side only; must be false for server side. NO data will be forwarded from the socket to the i2psocket other than initialI2PData if this is true.
    • I2PTunnelRunner

      private I2PTunnelRunner(Socket s, I2PSocket i2ps, Object slock, byte[] initialI2PData, byte[] initialSocketData, List<I2PSocket> sockList, I2PTunnelRunner.FailCallback onFail, boolean shouldStart)
      Base constructor.
      Parameters:
      s - the local socket, non-null
      i2ps - the I2P socket carrying the tunnel, non-null
      slock - the socket lock, non-null
      initialI2PData - may be null
      initialSocketData - may be null
      sockList - may be null. Caller must add i2ps to the list! It will be removed here on completion. Will synchronize on slock when removing.
      onFail - run when the transfer ends early; takes precedence over onTimeout
      shouldStart - should thread be started in constructor (bad, false recommended)
    • I2PTunnelRunner

      private I2PTunnelRunner(Socket s, I2PSocket i2ps, Object slock, byte[] initialI2PData, byte[] initialSocketData, List<I2PSocket> sockList, Runnable onTimeout, I2PTunnelRunner.FailCallback onFail, boolean keepAliveI2P, boolean keepAliveSocket, boolean shouldStart)
      Base constructor with keepAlive args.
      Parameters:
      s - the local socket, non-null
      i2ps - the I2P socket carrying the tunnel, non-null
      slock - the socket lock, non-null
      initialI2PData - may be null
      initialSocketData - may be null
      sockList - may be null. Caller must add i2ps to the list! It will be removed here on completion. Will synchronize on slock when removing.
      onTimeout - May be null. If non-null and no data (except initial data) was received, it will be run before closing s.
      onFail - run when the transfer ends early; takes precedence over onTimeout
      keepAliveI2P - Do not close the I2P socket when done.
      keepAliveSocket - Do not close the local socket when done. For client side only; must be false for server side. NO data will be forwarded from the socket to the i2psocket other than initialI2PData if this is true.
      shouldStart - should thread be started in constructor (bad, false recommended)
  • Method Details

    • emptyCycleDelayMs

      static long emptyCycleDelayMs(int cycle)
      Delay before empty-retry cycle cycle (1-based, after the cycle counter has been incremented). Cycle 1 races immediately (the prior attempt already failed). Later cycles back off exponentially so a reachable-but-empty dest cannot spin at full rate.
      Parameters:
      cycle - 1-based empty-retry cycle number
      Returns:
      delay in ms before invoking the reconnect callback
    • initialResponseExpired

      static boolean initialResponseExpired(long lastRequestWriteMs, long firstByteMs, long nowMs, long timeoutMs)
      Whether the first-response deadline has elapsed for the current request attempt (pure predicate).

      The deadline only ever arms before the first response byte: once any upstream byte has arrived the watchdog retires permanently and BODY_STALL_READ_TIMEOUT_MS takes over. An unset anchor means no request has been written (server-side runners, or a runner that never started), so nothing can be late.

      Parameters:
      lastRequestWriteMs - epoch-ms of the most recent request-side write upstream, 0 if none
      firstByteMs - epoch-ms of the first response byte, 0 if none yet
      nowMs - current epoch-ms, comparable with lastRequestWriteMs
      timeoutMs - deadline window in ms; non-positive disables it
      Returns:
      true if the deadline expired with no response byte
    • refillRaceBudget

      static long[] refillRaceBudget(long[] state, long now)
      Refill a per-dest race token bucket in place. State is {tokensMilli, lastRefillMs}; tokens are held in milli-units so a whole-second refill can be applied from a millisecond delta without truncation to zero.
      Parameters:
      state - non-null long[2] bucket, updated in place
      now - current epoch-ms, the base for the refill delta
      Returns:
      the same state array
    • tryConsumeRaceBudget

      static boolean tryConsumeRaceBudget(long[] state, long now, int costTokens)
      Try to consume costTokens from the race budget, refilling first. Insufficient balance leaves the bucket unchanged and returns false.
      Parameters:
      state - non-null long[2] bucket, updated in place when consumed
      now - current epoch-ms, forwarded to the refill
      costTokens - whole tokens to consume (1 = single open, 2 = dual-race)
      Returns:
      true if the budget allowed the open
    • stallCycleLimit

      static int stallCycleLimit(long contentLength)
      Size-scaled cap on consecutive no-progress resume cycles.

      A long transfer lives through proportionally more tunnel-pool churn than a small file, so the stall budget ramps by one cycle per RETRY_RAMP_UNIT_BYTES of the entity's verified Content-Length. Unknown or sub-1MB entities keep the MAX_EMPTY_RECONNECT_CYCLES baseline, and the result is capped at MAX_SCALED_STALL_CYCLES so a stuck destination still fails within a bounded browser wait.

      Parameters:
      contentLength - entity Content-Length in bytes, or -1 if unknown
      Returns:
      the stall-cycle cap for this entity; never below the baseline
    • totalCycleLimit

      static int totalCycleLimit(long contentLength)
      Size-scaled cap on total body-resume cycles regardless of progress.

      Progress resets the stall counter, but every resume attempt counts against this absolute cap, so it must grow with the entity the same way stallCycleLimit(long) does: one cycle per RETRY_RAMP_UNIT_BYTES of verified Content-Length, baseline below 1MB, capped at MAX_SCALED_RESUME_CYCLES so a pathological trickle still terminates.

      Parameters:
      contentLength - entity Content-Length in bytes, or -1 if unknown
      Returns:
      the total-cycle cap for this entity; never below the baseline
    • resumeDeadlineExpired

      static boolean resumeDeadlineExpired(long deadlineMs, long nowMs)
      Whether a resume sequence has run past its wall-clock deadline. Pure decision so the bound can be pinned without a live transfer.

      The cycle caps in stallCycleLimit(long) and totalCycleLimit(long) bound attempt count, not elapsed time: every attempt may block for a full connect timeout, so a large entity's 29x-scaled budget can hold a runner busy for hours against a destination that keeps answering but never finishes. This is the only bound expressed in the unit the browser actually waits in, and it must hold even while progress is being made and cycle budget remains.

      Parameters:
      deadlineMs - absolute deadline in ms, or NO_DEADLINE
      nowMs - current time in ms, ignored when no deadline applies
      Returns:
      true if the deadline applies and has been reached
    • scaledCycleLimit

      private static int scaledCycleLimit(long contentLength, int baseline, int maxCap)
      Shared size-scaled cycle cap. Pure so both limits stay pinned by one set of tests and neither can drift back to accepting an unclamped Content-Length.
      Parameters:
      contentLength - entity Content-Length in bytes, or -1 if unknown
      baseline - cap for entities at or below the ramp unit
      maxCap - hard ceiling on the ramp
      Returns:
      the cap for this entity, never below the baseline
    • getStartedOn

      public long getStartedOn()
      Returns the timestamp when this runner started.

      This value is set at construction time and represents when the runner was created, not when it started executing.

      Returns:
      the timestamp in milliseconds since epoch when this runner was created
    • setSuccessCallback

      public void setSuccessCallback(I2PTunnelRunner.SuccessCallback sc)
      Sets a callback to be invoked on successful data transfer.

      The callback is invoked after the first byte of data is received from the destination, not when the entire transfer completes. Only one of SuccessCallback, onTimeout, or onFail will be called.

      Parameters:
      sc - the callback to invoke on success, may be null
    • setReconnectCallback

      public void setReconnectCallback(I2PTunnelRunner.ReconnectCallback rc)
      Set the reconnect callback used for the empty-response retry.

      When set, and this is a no-request-body transfer (GET/HEAD, i.e. the toI2P forwarder was never started) that completes with zero upstream bytes, run() will call the callback to obtain a fresh I2P socket and re-drive the request on it, keeping the local browser socket open. The same callback is used for mid-body Range resume when a partial Content-Length body was already delivered. Only the onNoDataFailure(Exception) path triggers an empty reconnect; a mid-body death with totalReceived > 0 triggers resumeIncompleteBody(OutputStream) instead. A genuine non-empty completion (full body, or no Content-Length to resume) never reconnects.

      Parameters:
      rc - the callback, or null to disable reconnects
    • setExecutor

      public void setExecutor(Executor exec)
      Set the executor for submitting forwarder tasks. When null (default), forwarders use a fallback thread.
      Parameters:
      exec - the executor to run forwarders on, or null for the fallback thread
    • getRunnerExecutor

      Executor getRunnerExecutor()
      The tunnel's runner executor (set by setExecutor(Executor)), or null when the runner will spawn a dedicated thread instead.
      Returns:
      the executor, or null
    • getSocketIn

      protected InputStream getSocketIn() throws IOException
      Gets the TCP socket input stream.

      This method is protected to allow subclasses to override socket access for testing or special handling (e.g., SSL unwrapping).

      Returns:
      the TCP socket's input stream
      Throws:
      IOException - if the socket is closed
    • getSocketOut

      protected OutputStream getSocketOut() throws IOException
      Gets the TCP socket output stream.
      Returns:
      the TCP socket's output stream
      Throws:
      IOException - if the socket is closed
    • getKeepAliveI2P

      boolean getKeepAliveI2P()
      Checks if the I2P socket should be kept open after data transfer.

      On the client side, this is true only if the browser and server both support HTTP keep-alive. On the server side, it's true only if the client supports keep-alive.

      Returns:
      true if the I2P socket should remain open for reuse
    • getKeepAliveSocket

      boolean getKeepAliveSocket()
      Checks if the local socket should be kept open after data transfer.

      Usually true for client-side connections (browser to proxy). Always false for server-side connections (I2P to local service).

      Returns:
      true if the local socket should remain open for reuse
    • streamDone

      public void streamDone()
      The DoneCallback for the I2P socket.
      Specified by:
      streamDone in interface LimitOutputStream.DoneCallback
    • onNoDataFailure

      protected void onNoDataFailure()
      Invoke the no-data failure callback when a transfer completes (or aborts) without delivering any bytes from the I2P peer.

      This is the single choke point behind the "empty response" class of bugs: without it, a connection that is established to the proxy but never yields an upstream byte (server unresponsive, reset before first data, or the executor rejecting the forwarder) would end by closing the local socket with nothing written, which the browser reports as NS_ERROR_NET_EMPTY_RESPONSE. The HTTP client runner wires its I2PTunnelRunner.FailCallback to a handler that writes a well-formed HTTP error page to the browser socket before closing; this is what turns a silent empty close into a surfaced 5xx. The base implementation simply dispatches to the configured I2PTunnelRunner.FailCallback or onTimeout.

      Safe to call from any completion or exception path; _noDataHandled guarantees the callback runs at most once even when several paths race. Run even when totalSent > 0 (post body) — the absence of a response is still a failure. Never run when any upstream bytes were received.

    • onNoDataFailure

      protected void onNoDataFailure(Exception e)
      Invoke the no-data failure callback with an optional cause.
      Parameters:
      e - the failure cause, or null for a clean empty transfer
    • shouldFireNoDataFailure

      static boolean shouldFireNoDataFailure(long totalReceived, boolean handled)
      Whether a transfer with the given upstream byte count and handling state should trigger the no-data failure callback.

      This is the decision behind the "empty response" fix: the failure callback must fire iff no upstream bytes were received (an empty transfer is still a failure even when a POST body was sent upstream) and the callback has not already fired for this transfer. Extracted as a pure static predicate so the empty-response contract is unit-testable without a router or live socket.

      Parameters:
      totalReceived - upstream bytes received from the I2P peer
      handled - whether the no-data failure has already been signalled
      Returns:
      true if the failure callback should fire
    • shouldReconnectEmptyResponse

      static boolean shouldReconnectEmptyResponse(long totalReceived, boolean hasReconnectCallback, boolean retryableRequest)
      Whether an empty-upstream transfer should be retried on a fresh I2P connection.

      The HTTP client proxy re-sends an idempotent (GET/HEAD) request against a fresh I2P connection after a transfer that produced no upstream bytes, instead of closing the browser socket with nothing (which the browser reports as NS_ERROR_NET_EMPTY_RESPONSE). Reconnecting is only justified when the transfer was genuinely empty: once the peer has reported a complete HTTP response (even a definitive error such as a 5xx), totalReceived is positive and a reconnect is both pointless and harmful - it re-drives a request the outproxy already answered, contributing to the very congestion behind slow/failed streams. Guarding on upstream bytes actually received (rather than bytes written to the browser) is essential: a response whose browser write threw (e.g. Pipe closed) still counts as received, so it must terminate, not retry.

      Parameters:
      totalReceived - upstream bytes received from the I2P peer since reconnect reset
      hasReconnectCallback - whether a reconnect callback is installed
      retryableRequest - whether the buffered request is an idempotent, body-less GET/HEAD
      Returns:
      true if the transfer should be retried on a fresh connection
    • shouldResumeIncompleteBody

      static boolean shouldResumeIncompleteBody(long bodyReceived, long dataExpected, boolean hasReconnectCallback, boolean retryableRequest, boolean canRangeResume)
      Whether an incomplete Content-Length body should be resumed via HTTP Range on a fresh I2P connection (new tunnel).

      Used when the browser has already been sent response headers with a definite Content-Length, but the upstream stream died (tunnel failure, write timeout, stall) after only part of the entity arrived. I2P delays are expected; the download must not be abandoned. Reconnecting with Range: bytes=N- fetches only the remainder so the browser-visible byte stream stays contiguous. Only GET/HEAD qualify (idempotent, no request body). Resume is refused when the body cannot be spliced safely (unknown length, chunked, transparent gzip decode, or headers not yet emitted — the latter is the empty-response case handled instead).

      Parameters:
      bodyReceived - entity-body bytes already delivered to the browser
      dataExpected - original response Content-Length, or -1 if unknown
      hasReconnectCallback - whether a reconnect callback is installed
      retryableRequest - whether the buffered request is an idempotent GET/HEAD
      canRangeResume - whether the response stream can safely splice (see HTTPResponseOutputStream.canRangeResume())
      Returns:
      true if the transfer should resume on a fresh connection
    • shouldFallbackFullBody

      static boolean shouldFallbackFullBody(long bodyReceived, long dataExpected)
      Whether a non-Range full re-request is safe after a transient status (408/502/503/504) aborted a Range resume attempt.

      Used by the resume loop instead of shouldResumeIncompleteBody(long, long, boolean, boolean, boolean) while the fallback is pending: the response stream's header-written state was reset when it swallowed the failed resume headers, so the Range-specific canRangeResume gate can no longer pass even though headers and a partial body were already delivered to the browser. The remaining requirements are the same splice invariants — a definite Content-Length so the delivered prefix is known, and an incomplete body so there is still something to fetch. The caller (resume loop entry) has already required a reconnect callback and an idempotent GET/HEAD.

      Parameters:
      bodyReceived - entity-body bytes already delivered to the browser
      dataExpected - original response Content-Length, or -1 if unknown
      Returns:
      true if a fresh full-entity request may be issued
    • withRangeHeader

      static byte[] withRangeHeader(byte[] request, long start)
      Rewrite a buffered GET/HEAD request to resume at start via a Range header, replacing any Range the browser already sent.

      The header is inserted immediately before the terminating blank line of the header block. start <= 0 returns the request unchanged (full entity; no Range needed). A request with no header terminator is returned unchanged rather than corrupted.

      Parameters:
      request - the raw request bytes (request-line + headers), may be null
      start - first byte offset of the remaining entity (inclusive)
      Returns:
      a new request array with the Range header, or the original when no rewrite applies; never null if request is non-null
    • withoutRangeHeader

      static byte[] withoutRangeHeader(byte[] request)
      Return request with every Range: header line removed — the non-Range fallback form re-requested after a transient status (408/502/503/504) aborts a Range resume. The response then starts at byte 0, and the resume stream drops the already-delivered prefix so the splice stays seamless.
      Parameters:
      request - the raw request bytes (request-line + headers), may be null
      Returns:
      a copy without Range lines (original array when none present); null if request is null
    • indexOfHeaderEnd

      private static int indexOfHeaderEnd(byte[] data)
      Index of the first \r\n\r\n (or \n\n) header terminator.
      Parameters:
      data - request or header bytes, may be null
      Returns:
      index of the first byte of the terminator, or -1 if absent
    • stripRangeHeader

      private static byte[] stripRangeHeader(byte[] request, int headerEnd)
      Return a copy of request with any Range: header line removed. Only lines before the header terminator are considered.
      Parameters:
      request - raw request bytes
      headerEnd - index from indexOfHeaderEnd(byte[])
      Returns:
      request without Range lines (new array if stripped, else original)
    • isRangeHeaderLine

      private static boolean isRangeHeaderLine(byte[] data, int start, int end)
      Whether [start, end) is a Range: header line (not the request line).
      Parameters:
      data - the request bytes, including the request line
      start - first byte of the line
      end - one past the line (including CRLF)
      Returns:
      true if the line starts with Range: ignoring ASCII case
    • isRetryableRequest

      static boolean isRetryableRequest(byte[] initialData)
      Whether the buffered initial request is safe to re-send on a fresh connection in the "empty response" retry.

      A request is retryable iff it is idempotent and carries no streamed body, so re-sending it cannot duplicate a submission or split a byte stream mid-body. Only GET and HEAD qualify: they have no request body by definition and repeating them is safe. The leading ASCII method token is compared case-insensitively because initialI2PData holds the raw request bytes as the browser sent them. This mirrors the POST/PUT guard used when deciding whether to flush the initial packet before the body arrives, and is deliberately independent of the reconnect callback so a future caller cannot enable re-sends for a POST/PUT.

      Parameters:
      initialData - the buffered request (request-line + headers), may be null
      Returns:
      true if the request starts with GET or HEAD
    • getBodyProgress

      protected I2PTunnelRunner.BodyProgress getBodyProgress()
      Entity-body progress for a mid-body Range resume. Default: no HTTP response stream (server-side or non-HTTP runner) — resume never applies.
      Returns:
      body bytes delivered, content length (-1 if unknown), and whether headers were written / Range can splice; defaults disable resume
    • prepareBodyResume

      protected void prepareBodyResume()
      Prepare the HTTP response stream to swallow the next header block (mid-body Range resume). No-op when there is no response stream.
    • redriveReceiveForwarder

      private void redriveReceiveForwarder(OutputStream out, InputStream i2pin)
      Re-drive the I2P→browser forwarder inline after a reconnect, waiting up to the standard 180s for completion (same policy as the initial run).
      Parameters:
      out - browser-facing output stream (possibly HTTP-filtered)
      i2pin - input stream of the current I2P socket (updated field preferred)
    • pickRedriveStream

      static InputStream pickRedriveStream(InputStream supplied, I2PSocket sock) throws IOException
      Choose the stream a re-driven receive forwarder reads from.

      The caller-provided stream always wins when present: the dual-race winner is delivered as a PushbackInputStream whose buffer holds the first response byte the race already consumed, and reacquiring sock.getInputStream() would start past that byte, truncating the response header. A fresh socket stream is used only when the caller supplied none.

      Parameters:
      supplied - caller-provided stream (race winner's pushback wrapper or a fresh socket stream), may be null
      sock - current I2P socket to reacquire from, may be null
      Returns:
      supplied when non-null; otherwise sock's input stream; otherwise null
      Throws:
      IOException - if sock cannot open its input stream
    • raceEmptyPair

      I2PTunnelRunner.RaceWin raceEmptyPair(I2PSocket[] pair, byte[] request)
      Race two freshly connected sockets for an empty-response retry: write the buffered request to both, then the first socket to deliver a non-EOF byte wins. The loser is closed. A total failure (both empty, timed out, or errored) returns null so the outer cycle can burn an attempt. Pure I/O — no budget/permit decisions live here. All socket cleanup happens in finally: exactly the sockets not returned are closed on every exit path, including unexpected exceptions.
      Parameters:
      pair - two non-null sockets from I2PTunnelRunner.ReconnectCallback.reconnectPair(Exception)
      request - buffered request bytes, may be null (nothing to re-send)
      Returns:
      the winner with its pushback stream, or null if neither produced data
    • startRaceLeg

      private void startRaceLeg(String label, I2PSocket sock, PushbackInputStream in, AtomicReference<I2PTunnelRunner.RaceWin> winner, AtomicInteger failures, CountDownLatch settled)
      One race leg: read a single first byte, unread it into a pushback stream, and publish the win. EOF / timeout / IOException counts as a leg failure; the second failure (or overall timeout) settles the race.
      Parameters:
      label - "A" or "B" for log context
      sock - the socket this leg is reading from (closed by caller on loss)
      in - pushback stream wrapping that socket's input
      winner - shared winner slot (first CAS wins)
      failures - shared failure counter
      settled - counted down when the race has a winner or both legs failed
    • closeQuietly

      private static void closeQuietly(I2PSocket sock)
      Closes a socket, ignoring any error.
      Parameters:
      sock - the socket to close, may be null
    • sleepQuietly

      private static boolean sleepQuietly(long delayMs)
      Sleeps, reporting whether interrupted.
      Parameters:
      delayMs - how long to sleep, in ms
      Returns:
      true if the sleep was interrupted
    • resumeIncompleteBody

      private void resumeIncompleteBody(OutputStream out)
      Resume an incomplete Content-Length body on a fresh I2P connection.

      Rotates to a new tunnel via I2PTunnelRunner.ReconnectCallback, re-sends the buffered GET/HEAD with Range: bytes=N- for the undelivered remainder, and splices the second response body onto the browser stream without re-emitting headers. After a transient status (408/502/503/504) aborts a Range attempt the stream can no longer splice, so the next attempt re-requests the full entity with all Range headers stripped; the fresh 200's already-delivered prefix is then dropped so the splice stays seamless. The budget is progress-based: only consecutive stall cycles (no new body bytes since the previous attempt) count against the size-scaled cap from stallCycleLimit(long) (baseline MAX_EMPTY_RECONNECT_CYCLES); any forward progress resets that counter so a slowly-advancing download is never abandoned for lack of progress. totalCycleLimit(long) is an absolute safety cap on total attempts regardless of progress.

      Parameters:
      out - browser-facing output stream
    • wasTransientResumeFailure

      protected boolean wasTransientResumeFailure()
      Whether the last body-resume attempt failed with a transient status (408/502/503/504) rather than a real stall. Subclasses with an HTTP response stream override this; the base runner has no stream so it always returns false.
      Returns:
      true if the last resume aborted with a transient status
    • hasTransientResumeFailure

      protected boolean hasTransientResumeFailure()
      Whether a transient status (408/502/503/504) is currently latched on the response stream — observed without clearing, unlike wasTransientResumeFailure(). The resume loop clears the latch when it observes the failure. The forwarder's finally-block runs first and uses this peek to keep the browser stream open while the non-Range fallback is still pending.
      Returns:
      true if a transient status aborted the last resume attempt and the loop has not yet observed it
    • isBodyResumePending

      private boolean isBodyResumePending()
      Whether an incomplete body is still eligible for resume — used by the forwarder finally-block to keep the browser stream open. A latched transient status means the non-Range fallback is still pending. Closing the browser stream now would sever the splice target before the loop can re-request the full entity.
      Returns:
      true if resume should run (or may still run) after this forwarder
    • startsWithIgnoreCase

      private static boolean startsWithIgnoreCase(byte[] data, byte[] prefix)
      Case-insensitive ASCII prefix match of prefix against data.
      Parameters:
      data - the bytes to test, may be null
      prefix - the byte sequence to match at the start of data
      Returns:
      true if data is non-null, at least as long as prefix, and equals prefix ignoring ASCII case
    • trackInitialResponseDeadline

      protected boolean trackInitialResponseDeadline()
      Whether this runner type arms the first-response deadline watchdog. False in the base runner so idle long-lived sessions (HTTP CONNECT, SOCKS, IRC, server-side tunnels) are never torn down for silence; the HTTP request/response runner overrides it to true. For CONNECT the remote proxy's 200 response would retire the watchdog anyway, but the default keeps the deadline strictly scoped to request/response flows.
      Returns:
      true to schedule the initial-response watchdog
    • noteRequestWritten

      void noteRequestWritten()
      Record a request-side write upstream: start (or slide, or restart) the first-response deadline for the current attempt. Sliding on every toI2P write keeps a slow POST upload from tripping the deadline; a re-send after an empty response restarts it for the fresh attempt. Clears the fired latch so the new attempt may fire independently.
    • noteRequestWritten

      void noteRequestWritten(long nowMs)
      noteRequestWritten() with an explicit timestamp (test hook).
      Parameters:
      nowMs - epoch-ms to record as the request write anchor
    • noteFirstResponseByte

      void noteFirstResponseByte()
      Record the first response byte upstream: permanently retires the initial-response watchdog from here on (BODY_STALL_READ_TIMEOUT_MS covers further stalling).
    • checkInitialResponseDeadline

      boolean checkInitialResponseDeadline(long nowMs)
      Enforce the first-response deadline: close the I2P socket if no response byte arrived within initialResponseTimeoutMs of the last request-side write. Closing the socket is what unblocks the receive forwarder (a blocked read throws once the stream closes), and run()'s existing empty-transfer path then invokes the no-data failure callback — the single choke point — so the browser sees a proper 5xx instead of an indefinite hang. At most one close per request attempt; a subsequent re-send re-arms via noteRequestWritten().
      Parameters:
      nowMs - current epoch-ms, from System.currentTimeMillis()
      Returns:
      true if the socket was closed by this call
    • scheduleInitialResponseWatchdog

      private void scheduleInitialResponseWatchdog()
      Arm the first-response watchdog after the request has been written. Idempotent; a no-op when trackInitialResponseDeadline() is false or the timer rejects scheduling (headless/broken context).
    • cancelInitialResponseWatchdog

      private void cancelInitialResponseWatchdog()
      Disarm the initial-response watchdog; called from run()'s finally so a completed runner never leaves a timer event behind. Safe to call when never scheduled.
    • run

      public void run()
      Serves one connection: writes the buffered request, starts the forwarders and keeps them going, reconnecting if the upstream returns nothing.
      Specified by:
      run in interface Runnable
      Overrides:
      run in class I2PThread
    • close

      protected void close(OutputStream out, InputStream in, OutputStream i2pout, InputStream i2pin, Socket s, I2PSocket i2ps, Thread t1, Thread t2) throws InterruptedException
      Warning - overridden in I2PTunnelHTTPClientRunner. Here we ignore keepalive and always close both sides. The HTTP flavor handles keepalive.
      Parameters:
      out - may be null
      in - may be null
      i2pout - may be null
      i2pin - may be null
      s - the local socket to close, non-null
      i2ps - the I2P socket to close, non-null
      t1 - may be null
      t2 - may be null, ignored, we only join t1
      Throws:
      InterruptedException - if the joining thread is interrupted while waiting on t1
    • removeRef

      private void removeRef()
      Remove this runner's I2PSocket from the shared socket list.