···3434}
35353636/**
3737+ * True for failures that are worth retrying immediately within the same job: a
3838+ * flaky knot SSH endpoint that resets or times out the connection at the
3939+ * handshake, or drops the channel mid-stream. These clear within seconds, so a
4040+ * short in-job retry usually succeeds where waiting out the queue's minutes-long
4141+ * backoff would leave the mirror stale.
4242+ *
4343+ * A `RemoteRejectedError` is never transient-transport: it's a protocol-level
4444+ * verdict (CAS loss, repo gone, auth refused, pack too big) that a reconnect
4545+ * won't change, so those fall through to the caller's own handling.
4646+ */
4747+export function isTransientTransport(err: unknown): boolean {
4848+ if (!(err instanceof WireError) || err instanceof RemoteRejectedError) return false
4949+ const lc = err.message.toLowerCase()
5050+ return (
5151+ lc.includes('econnreset')
5252+ || lc.includes('connection reset')
5353+ || lc.includes('timed out')
5454+ || lc.includes('handshake')
5555+ || lc.includes('no advertisement')
5656+ || lc.includes('connection lost')
5757+ || lc.includes('stdin closed')
5858+ || lc.includes('receive-pack exited')
5959+ || lc.includes('empty report-status')
6060+ )
6161+}
6262+6363+/**
3764 * Classify ssh / sshd / knot stderr (the child process's stderr band, since
3865 * we deliberately do not request side-band multiplexing). Returns null when
3966 * nothing matches so the caller can fall back to a generic transient error.
+41-4
server/utils/splice.ts
···11+import { isTransientTransport } from './git-wire/errors'
12import {
23 type ReceivePackFactory,
34 ReceivePackSession,
···1314/** Cap haves so a repo with thousands of refs can't bloat the negotiation. */
1415const MAX_HAVES = 256
15161717+/**
1818+ * In-job retry for the flaky knot SSH endpoint. A burst of connection resets at
1919+ * the handshake typically clears within a second or two, so retrying a few
2020+ * times inside the job lands the push now rather than after the queue's
2121+ * minutes-long backoff (or, worse, after the attempt ceiling gives up and
2222+ * leaves the mirror stale until the next push). Only transient transport
2323+ * failures are retried; protocol verdicts re-throw immediately.
2424+ */
2525+const TRANSPORT_RETRIES = 3
2626+const TRANSPORT_RETRY_DELAY_MS = 1_500
2727+2828+export async function withTransportRetry<T>(op: () => Promise<T>, delayMs = TRANSPORT_RETRY_DELAY_MS): Promise<T> {
2929+ let lastErr: unknown
3030+ for (let attempt = 0; attempt <= TRANSPORT_RETRIES; attempt++) {
3131+ try {
3232+ // eslint-disable-next-line no-await-in-loop -- sequential retries by design
3333+ return await op()
3434+ }
3535+ catch (err) {
3636+ if (!isTransientTransport(err)) throw err
3737+ lastErr = err
3838+ if (attempt < TRANSPORT_RETRIES) {
3939+ // eslint-disable-next-line no-await-in-loop -- deliberate backoff between retries
4040+ await new Promise(resolve => setTimeout(resolve, delayMs))
4141+ }
4242+ }
4343+ }
4444+ throw lastErr
4545+}
4646+1647function maxPackBytes(): number {
1748 const raw = process.env.NUXT_MAX_PACK_BYTES
1849 if (!raw) return DEFAULT_MAX_PACK_BYTES
···6091 * keeps the knot's advertised tip as the authoritative compare-and-swap base.
6192 */
6293export async function splicePush(params: SplicePushParams): Promise<SplicePushResult> {
6363- const factory = await sshFactory(params.installationId, params.knot, params.repoDid)
6464- return runSplice(factory, params)
9494+ return withTransportRetry(async () => {
9595+ // Fresh factory (and therefore fresh SSH connection) per attempt: a reset
9696+ // connection can't be reused.
9797+ const factory = await sshFactory(params.installationId, params.knot, params.repoDid)
9898+ return runSplice(factory, params)
9999+ })
65100}
6610167102/** The fetch + push exchange over an open session. Split out for the wire test. */
···118153 repoDid: string
119154 ref: string
120155}): Promise<SpliceDeleteResult> {
121121- const factory = await sshFactory(params.installationId, params.knot, params.repoDid)
122122- return runSpliceDelete(factory, params.ref)
156156+ return withTransportRetry(async () => {
157157+ const factory = await sshFactory(params.installationId, params.knot, params.repoDid)
158158+ return runSpliceDelete(factory, params.ref)
159159+ })
123160}
124161125162/** The delete exchange over an open session. Split out for the wire test. */
+63
test/unit/transient-transport.spec.ts
···11+import { describe, expect, it, vi } from 'vitest'
22+import { isTransientTransport, RemoteRejectedError, WireError } from '../../server/utils/git-wire/errors'
33+import { withTransportRetry } from '../../server/utils/splice'
44+55+describe('isTransientTransport', () => {
66+ it('matches the knot handshake/reset failures we want to retry in-job', () => {
77+ const messages = [
88+ 'receive-pack: no advertisement (stderr: ssh error: read ECONNRESET)',
99+ 'receive-pack: no advertisement (stderr: ssh error: Timed out while waiting for handshake)',
1010+ 'kex_exchange_identification: Connection reset by peer',
1111+ 'receive-pack: no advertisement (stderr: ssh error: Connection lost before handshake)',
1212+ 'receive-pack: stdin closed before pack finished streaming',
1313+ 'receive-pack exited 128 (stderr: empty)',
1414+ 'receive-pack: empty report-status (stderr: empty)',
1515+ ]
1616+ for (const m of messages) {
1717+ expect(isTransientTransport(new WireError(m))).toBe(true)
1818+ }
1919+ })
2020+2121+ it('never retries a protocol verdict (RemoteRejectedError)', () => {
2222+ for (const reason of ['stale-old-sha', 'repo-gone', 'auth-rejected', 'too-big'] as const) {
2323+ expect(isTransientTransport(new RemoteRejectedError('nope', reason))).toBe(false)
2424+ }
2525+ })
2626+2727+ it('does not retry unrelated wire errors or plain errors', () => {
2828+ expect(isTransientTransport(new WireError('knot receive-pack does not advertise report-status'))).toBe(false)
2929+ expect(isTransientTransport(new Error('read ECONNRESET'))).toBe(false)
3030+ expect(isTransientTransport('read ECONNRESET')).toBe(false)
3131+ })
3232+})
3333+3434+describe('withTransportRetry', () => {
3535+ it('retries a transient transport failure and eventually succeeds', async () => {
3636+ const failures = [
3737+ new WireError('ssh error: read ECONNRESET'),
3838+ new WireError('ssh error: Timed out while waiting for handshake'),
3939+ ]
4040+ let calls = 0
4141+ const op = vi.fn<() => Promise<string>>(async () => {
4242+ const err = failures[calls++]
4343+ if (err) throw err
4444+ return 'ok'
4545+ })
4646+ const result = await withTransportRetry(op, 0)
4747+ expect(result).toBe('ok')
4848+ expect(op).toHaveBeenCalledTimes(3)
4949+ })
5050+5151+ it('re-throws a protocol verdict immediately without retrying', async () => {
5252+ const op = vi.fn<() => Promise<never>>().mockRejectedValue(new RemoteRejectedError('lost', 'stale-old-sha'))
5353+ await expect(withTransportRetry(op, 0)).rejects.toBeInstanceOf(RemoteRejectedError)
5454+ expect(op).toHaveBeenCalledTimes(1)
5555+ })
5656+5757+ it('gives up after the retry budget on persistent transport failure', async () => {
5858+ const op = vi.fn<() => Promise<never>>().mockRejectedValue(new WireError('ssh error: read ECONNRESET'))
5959+ await expect(withTransportRetry(op, 0)).rejects.toBeInstanceOf(WireError)
6060+ // initial attempt + 3 retries
6161+ expect(op).toHaveBeenCalledTimes(4)
6262+ })
6363+})