···9595 stdin.pipe(channel)
9696 channel.pipe(stdout)
9797 channel.stderr.on('data', appendStderr)
9898+ // A channel-level error (peer reset, transport dying mid-stream) would
9999+ // otherwise be lost: the write side is a PassThrough whose 'error' we
100100+ // swallow, and a half-sent pack reaches the knot as a corrupt stream
101101+ // it reports back as an `unpack` failure. Fold it into the stderr band
102102+ // so push() can tell a truncated push apart from a clean one.
103103+ channel.on('error', (err: Error) => { if (!killed) connError = err })
98104 channel.on('exit', code => settle(typeof code === 'number' ? code : null))
99105 channel.on('close', () => { client.end(); stdout.end() })
100106 })
···128134 username: 'git',
129135 privateKey: target.privateKey,
130136 readyTimeout: 8_000,
137137+ // The channel sits idle while we fetch the thin pack from GitHub (we need
138138+ // the knot's advertised tips as haves before we can ask for the delta).
139139+ // Keepalives stop the knot or an intermediary from dropping that idle
140140+ // channel out from under us, which otherwise surfaces as a truncated pack
141141+ // (`unpack` failure) once we resume streaming.
142142+ keepaliveInterval: 5_000,
143143+ keepaliveCountMax: 6,
131144 hostVerifier: () => true,
132145 })
133146···208221 else this.proc.stdin.end()
209222210223 const report = await this.reader.readUntilFlush()
224224+ // If the transport died while we were streaming the pack, the knot saw a
225225+ // truncated stream and reports it as an `unpack` failure (e.g. "pack
226226+ // signature mismatch"). That reads like a terminal protocol error but is
227227+ // really a transient network fault, and a naive retry replays the same
228228+ // broken pipe. Check the channel/exit state first and surface it as a
229229+ // plain transient WireError so the queue retries with a fresh session.
230230+ const exitCode = await this.proc.done.catch(() => null)
231231+ const transport = classifySshStderr(this.proc.stderr())
232232+ if (transport) throw transport
233233+ if (exitCode !== null && exitCode !== 0) {
234234+ throw new WireError(`receive-pack exited ${exitCode} (stderr: ${this.proc.stderr().trim() || 'empty'})`)
235235+ }
211236 parseReportStatus(report ?? [], updates, this.proc.stderr())
212237 }
213238 finally {
···272297async function pipePack(stdin: NodeJS.WritableStream, packStream: AsyncIterable<Buffer>): Promise<void> {
273298 const src = Readable.from(packStream)
274299 await new Promise<void>((resolve, reject) => {
275275- src.on('error', reject)
276276- stdin.on('error', reject)
300300+ let settled = false
301301+ const done = (err?: Error) => {
302302+ if (settled) return
303303+ settled = true
304304+ if (err) reject(err)
305305+ else resolve()
306306+ }
307307+ // A `close` before `finish` means the write side went away before the pack
308308+ // was fully flushed (the knot's channel reset mid-stream). Resolving there
309309+ // would hand a truncated pack to report-status parsing; reject so push()
310310+ // treats it as the transient transport failure it is.
311311+ src.on('error', done)
312312+ stdin.on('error', done)
313313+ stdin.on('finish', () => done())
314314+ stdin.on('close', () => done(new WireError('receive-pack: stdin closed before pack finished streaming')))
277315 src.pipe(stdin, { end: true })
278278- stdin.on('finish', resolve)
279279- stdin.on('close', resolve)
280316 })
281317}
+49-2
test/unit/receive-pack.spec.ts
···11import { execFileSync } from 'node:child_process'
22+import { PassThrough } from 'node:stream'
33+import { Buffer } from 'node:buffer'
24import { afterEach, beforeEach, describe, expect, it } from 'vitest'
33-import { RemoteRejectedError } from '../../server/utils/git-wire/errors'
44-import { ReceivePackSession } from '../../server/utils/git-wire/receive-pack'
55+import { RemoteRejectedError, WireError } from '../../server/utils/git-wire/errors'
66+import { ReceivePackSession, type ReceivePackProcess } from '../../server/utils/git-wire/receive-pack'
77+import { encodePktLine, flushPkt } from '../../server/utils/git-wire/pkt-line'
58import { ZERO_SHA } from '../../server/utils/git-wire/refs'
69import { fakeGithubFetch, GitFixture, localReceivePackFactory } from '../utils/git-wire'
710import { fetchPack } from '../../server/utils/git-wire/upload-pack'
···123126 }
124127 }
125128 await expect(ReceivePackSession.open(stalled, 50)).rejects.toThrow(/end of stream|advertisement/)
129129+ })
130130+131131+ it('surfaces a mid-push channel death as a transient WireError, not a report parse', async () => {
132132+ // Advertise one ref so open() succeeds, then model the transport dying
133133+ // while the pack streams: stdin closes early and the process exits non-zero
134134+ // with no report-status. The knot in production would report a truncated
135135+ // pack as an `unpack` failure; the session must instead throw a plain
136136+ // (transient, retryable) WireError.
137137+ const factory = () => {
138138+ const stdin = new PassThrough()
139139+ const advertisement = Buffer.concat([
140140+ encodePktLine(`${ZERO_SHA} refs/heads/main\0report-status\n`),
141141+ flushPkt,
142142+ ])
143143+ const stdout = new PassThrough()
144144+ stdout.write(advertisement)
145145+ let resolveDone: (code: number | null) => void
146146+ const done = new Promise<number | null>(r => { resolveDone = r })
147147+ // Let stdin drain (so writeAll + pipePack complete their writes), but end
148148+ // stdout without a report-status flush and exit non-zero, as a reset ssh
149149+ // channel would after eating a partial pack.
150150+ stdin.resume()
151151+ stdin.on('finish', () => {
152152+ stdout.end()
153153+ resolveDone(128)
154154+ })
155155+ const proc: ReceivePackProcess = {
156156+ stdin,
157157+ stdout,
158158+ stderr: () => '',
159159+ kill: () => { stdout.end(); resolveDone(128) },
160160+ done,
161161+ }
162162+ return proc
163163+ }
164164+165165+ async function* pack(): AsyncGenerator<Buffer> {
166166+ yield Buffer.from('PACK')
167167+ yield Buffer.alloc(1024)
168168+ }
169169+170170+ const session = await ReceivePackSession.open(factory)
171171+ await expect(session.push([{ ref: 'refs/heads/main', old: ZERO_SHA, next: 'a'.repeat(40) }], pack()))
172172+ .rejects.toBeInstanceOf(WireError)
126173 })
127174128175 it('pushes an annotated tag', async () => {
+88
test/unit/splice-negotiation.spec.ts
···11+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
22+import { fetchPack } from '../../server/utils/git-wire/upload-pack'
33+import { fakeGithubFetch, GitFixture, localReceivePackFactory } from '../utils/git-wire'
44+import { ReceivePackSession } from '../../server/utils/git-wire/receive-pack'
55+import { ZERO_SHA } from '../../server/utils/git-wire/refs'
66+77+async function drain(gen: AsyncGenerator<Buffer>): Promise<Buffer> {
88+ const parts: Buffer[] = []
99+ for await (const c of gen) parts.push(c)
1010+ return Buffer.concat(parts)
1111+}
1212+1313+describe('fetchPack negotiation edge cases', () => {
1414+ let fx: GitFixture
1515+ let realFetch: typeof globalThis.fetch
1616+1717+ beforeEach(() => {
1818+ fx = new GitFixture()
1919+ realFetch = globalThis.fetch
2020+ })
2121+2222+ afterEach(() => {
2323+ globalThis.fetch = realFetch
2424+ fx.cleanup()
2525+ })
2626+2727+ function firstBytes(buf: Buffer, n = 4): string {
2828+ return buf.subarray(0, n).toString('ascii')
2929+ }
3030+3131+ it('pack begins with the PACK signature when a have is a real common commit', async () => {
3232+ const gh = fx.initBare('gh.git')
3333+ const work = fx.initWork('work')
3434+ const base = fx.commit(work, 'a.txt', 'hello')
3535+ fx.pushTo(work, gh, 'HEAD:refs/heads/main')
3636+ const head = fx.commit(work, 'b.txt', 'world')
3737+ fx.pushTo(work, gh, 'HEAD:refs/heads/main')
3838+3939+ globalThis.fetch = fakeGithubFetch(new Map([['owner/repo', gh]])) as unknown as typeof globalThis.fetch
4040+ const { pack } = await fetchPack({ repoFullName: 'owner/repo', token: 't', want: head, haves: [base], maxBytes: 1 << 30 })
4141+ expect(firstBytes(await drain(pack))).toBe('PACK')
4242+ })
4343+4444+ it('pack begins with PACK when a have is unknown to GitHub (diverged knot ref)', async () => {
4545+ const gh = fx.initBare('gh.git')
4646+ const work = fx.initWork('work')
4747+ const base = fx.commit(work, 'a.txt', 'hello')
4848+ fx.pushTo(work, gh, 'HEAD:refs/heads/main')
4949+ const head = fx.commit(work, 'b.txt', 'world')
5050+ fx.pushTo(work, gh, 'HEAD:refs/heads/main')
5151+5252+ // A commit GitHub has never seen (exists only on a throwaway repo). The
5353+ // knot could advertise such a tip; we must not let it corrupt the stream.
5454+ const other = fx.initWork('other')
5555+ const stranger = fx.commit(other, 'x.txt', 'stranger')
5656+5757+ globalThis.fetch = fakeGithubFetch(new Map([['owner/repo', gh]])) as unknown as typeof globalThis.fetch
5858+ const { pack } = await fetchPack({ repoFullName: 'owner/repo', token: 't', want: head, haves: [stranger, base], maxBytes: 1 << 30 })
5959+ expect(firstBytes(await drain(pack))).toBe('PACK')
6060+ })
6161+6262+ it('pushes into a knot that advertises a stranger tip alongside the base', async () => {
6363+ const gh = fx.initBare('gh.git')
6464+ const work = fx.initWork('work')
6565+ const base = fx.commit(work, 'a.txt', 'hello')
6666+ fx.pushTo(work, gh, 'HEAD:refs/heads/main')
6767+ const head = fx.commit(work, 'b.txt', 'world')
6868+ fx.pushTo(work, gh, 'HEAD:refs/heads/main')
6969+7070+ // Knot has the base on main plus an unrelated tip on another ref, so its
7171+ // advertisement (and therefore our have-set) mixes a real common commit
7272+ // with one GitHub can't resolve.
7373+ const knot = fx.initBare('knot.git')
7474+ fx.pushTo(work, knot, `${base}:refs/heads/main`)
7575+ const other = fx.initWork('other')
7676+ const stranger = fx.commit(other, 'x.txt', 'stranger')
7777+ fx.pushTo(other, knot, `${stranger}:refs/heads/wip`)
7878+7979+ globalThis.fetch = fakeGithubFetch(new Map([['owner/repo', gh]])) as unknown as typeof globalThis.fetch
8080+8181+ const session = await ReceivePackSession.open(localReceivePackFactory(knot))
8282+ const haves = [...new Set(session.tips.values())].filter(s => s !== ZERO_SHA)
8383+ const { pack } = await fetchPack({ repoFullName: 'owner/repo', token: 't', want: head, haves, maxBytes: 1 << 30 })
8484+ await session.push([{ ref: 'refs/heads/main', old: base, next: head }], pack)
8585+8686+ expect(fx.revParse(knot, 'refs/heads/main')).toBe(head)
8787+ })
8888+})
+44
test/unit/splice-thin-pack.spec.ts
···11+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
22+import { runSplice } from '../../server/utils/splice'
33+import { fakeGithubFetch, GitFixture, localReceivePackFactory } from '../utils/git-wire'
44+55+describe('runSplice thin-pack negotiation (knot already holds the base)', () => {
66+ let fx: GitFixture
77+ let realFetch: typeof globalThis.fetch
88+99+ beforeEach(() => {
1010+ fx = new GitFixture()
1111+ realFetch = globalThis.fetch
1212+ })
1313+1414+ afterEach(() => {
1515+ globalThis.fetch = realFetch
1616+ fx.cleanup()
1717+ })
1818+1919+ it('pushes an incremental update where the knot advertises the base commit as a have', async () => {
2020+ // GitHub side: two commits on main (base -> head).
2121+ const gh = fx.initBare('gh.git')
2222+ const work = fx.initWork('work')
2323+ const base = fx.commit(work, 'a.txt', 'hello')
2424+ fx.pushTo(work, gh, 'HEAD:refs/heads/main')
2525+ const head = fx.commit(work, 'b.txt', 'world')
2626+ fx.pushTo(work, gh, 'HEAD:refs/heads/main')
2727+2828+ // Knot side: already has `base` on main (the state after the first sync).
2929+ const knot = fx.initBare('knot.git')
3030+ fx.pushTo(work, knot, `${base}:refs/heads/main`)
3131+3232+ globalThis.fetch = fakeGithubFetch(new Map([['owner/repo', gh]])) as unknown as typeof globalThis.fetch
3333+3434+ const result = await runSplice(localReceivePackFactory(knot), {
3535+ repoFullName: 'owner/repo',
3636+ ref: 'refs/heads/main',
3737+ want: head,
3838+ token: 't',
3939+ })
4040+4141+ expect(result.status).toBe('synced')
4242+ expect(fx.revParse(knot, 'refs/heads/main')).toBe(head)
4343+ })
4444+})