diff --git a/src/askpass.test.ts b/src/askpass.test.ts index 2f744bb..70d2fb3 100644 --- a/src/askpass.test.ts +++ b/src/askpass.test.ts @@ -8,7 +8,10 @@ import { AskpassBridge } from "./askpass"; const run = promisify(execFile); let bridge: AskpassBridge | undefined; -afterEach(() => bridge?.stop()); +afterEach(async () => { + bridge?.stop(); + bridge = undefined; +}); test("helper script round-trips a prompt to onPrompt and returns the answer", async () => { const dir = mkdtempSync(join(tmpdir(), "jp-ask-")); @@ -23,3 +26,58 @@ test("helper script round-trips a prompt to onPrompt and returns the answer", as expect(stdout.trim()).toBe("s3cret"); rmSync(dir, { recursive: true, force: true }); }); + +test("onPrompt rejection: helper process completes promptly and returns empty answer", async () => { + const dir = mkdtempSync(join(tmpdir(), "jp-ask-reject-")); + bridge = new AskpassBridge({ + dir, + onPrompt: async (_p) => { throw new Error("user cancelled"); }, + }); + const env = await bridge.start(); + + // The helper must exit within 3 seconds; if it hangs the test times out with a clear error. + const result = await Promise.race([ + run(env.GIT_ASKPASS, ["Password:"], { + env: { ...process.env, JEKYLL_ASKPASS_SOCK: env.JEKYLL_ASKPASS_SOCK }, + }).then(({ stdout }) => ({ timedOut: false, stdout })) + .catch(() => ({ timedOut: false, stdout: "" })), + new Promise<{ timedOut: true; stdout: string }>((resolve) => + setTimeout(() => resolve({ timedOut: true, stdout: "" }), 3000) + ), + ]); + + expect(result.timedOut, "helper process hung instead of exiting promptly").toBe(false); + // Empty answer is fine — git will fail fast on its own + expect(result.stdout.trim()).toBe(""); + rmSync(dir, { recursive: true, force: true }); +}); + +test("restart on same dir: stop() then start() again succeeds with no EADDRINUSE", async () => { + const dir = mkdtempSync(join(tmpdir(), "jp-ask-restart-")); + + // First session + bridge = new AskpassBridge({ + dir, + onPrompt: async (p) => (p.includes("user") ? "bob" : "pass1"), + }); + const env1 = await bridge.start(); + const { stdout: out1 } = await run(env1.GIT_ASKPASS, ["username:"], { + env: { ...process.env, JEKYLL_ASKPASS_SOCK: env1.JEKYLL_ASKPASS_SOCK }, + }); + expect(out1.trim()).toBe("bob"); + bridge.stop(); + bridge = undefined; + + // Second session — same dir; must not throw EADDRINUSE + bridge = new AskpassBridge({ + dir, + onPrompt: async (p) => (p.includes("Password") ? "newpass" : "carol"), + }); + const env2 = await bridge.start(); + const { stdout: out2 } = await run(env2.GIT_ASKPASS, ["Password for 'https://y':"], { + env: { ...process.env, JEKYLL_ASKPASS_SOCK: env2.JEKYLL_ASKPASS_SOCK }, + }); + expect(out2.trim()).toBe("newpass"); + + rmSync(dir, { recursive: true, force: true }); +}); diff --git a/src/askpass.ts b/src/askpass.ts index 128d85f..c498e20 100644 --- a/src/askpass.ts +++ b/src/askpass.ts @@ -1,5 +1,5 @@ import { createServer, Server } from "node:net"; -import { chmodSync, writeFileSync } from "node:fs"; +import { chmodSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; export class AskpassBridge { @@ -18,15 +18,34 @@ export class AskpassBridge { this.sock = join(this.opts.dir, "askpass.sock"); this.script = join(this.opts.dir, "askpass.sh"); + // Best-effort removal of a stale socket from a previous unclean shutdown. + try { unlinkSync(this.sock); } catch { /* ignore ENOENT */ } + this.server = createServer({ allowHalfOpen: true }, (conn) => { let buf = ""; conn.on("data", (d) => (buf += d.toString())); - conn.on("end", async () => { - const answer = await this.opts.onPrompt(buf.replace(/\n$/, "")); - conn.end(answer.endsWith("\n") ? answer : answer + "\n"); + conn.on("end", () => { + // Wrap onPrompt in try/catch so a rejection never hangs the git/ssh process. + this.opts.onPrompt(buf.replace(/\n$/, "")).then( + (answer) => { + conn.end(answer.endsWith("\n") ? answer : answer + "\n"); + }, + (_err) => { + // User cancelled or onPrompt threw — send empty reply so git fails fast. + conn.end("\n"); + } + ); + }); + }); + + // Fix: reject the promise on listen error (e.g. EADDRINUSE) instead of hanging. + await new Promise((res, rej) => { + this.server!.once("error", rej); + this.server!.listen(this.sock, () => { + this.server!.removeListener("error", rej); + res(); }); }); - await new Promise((res) => this.server!.listen(this.sock, res)); // Helper: send argv[1] (the prompt) to the socket, print the reply. // Uses a Node one-liner for reliable EOF signalling across platforms @@ -56,5 +75,8 @@ export class AskpassBridge { stop(): void { this.server?.close(); this.server = undefined; + // Clean up socket and helper script to prevent stale-socket EADDRINUSE on restart. + try { unlinkSync(this.sock); } catch { /* ignore ENOENT */ } + try { unlinkSync(this.script); } catch { /* ignore ENOENT */ } } }