Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

A push cut off by a memory reset is retried once; the concurrent-push e2e skips wrangler dev's proxy (#244)

* A push cut off by a memory reset of its repository is handed over once more, like a deploy's
* The concurrent-push e2e sends its pushes to the Worker itself, past wrangler dev's proxy
test-bot authored 1 parent 4789265commit 274d73a6f691a3e61347c243c4c51a13028147f8Browse files
3 files changed+43−7
modifiedapps/diffinery/src/http/git.ts+9−1View file
@@ -69,7 +69,7 @@
6969 try {
7070 return await stub.gitReceivePack(body, caller, { moved, retry: attempt > 1 });
7171 } catch (e) {
72- if (attempt >= PUSH_TRIES || !resetByDeploy(e)) throw e;
72+ if (attempt >= PUSH_TRIES || !(resetByDeploy(e) || resetForMemory(e))) throw e;
7373 // A stub that saw its object reset is broken; a new one reaches the new code.
7474 stub = repoStub(env, repoId);
7575 }
@@ -81,6 +81,14 @@
8181
8282 const PUSH_TRIES = 2;
8383
84+/**
85+ * The repository's object was reset for going past its memory. Pushes to one repository take turns
86+ * for memory (RepoDO.gitReceivePack), so a push cut off this way was most likely caught up in another
87+ * request's reset, not the cause: like a deploy's reset, it is handed over once more, which moves the
88+ * refs the cut-off attempt didn't and sends the push events it may not have sent.
89+ */
90+export const resetForMemory = (e: unknown) => e instanceof Error && /exceeded its memory limit and was reset/i.test(e.message);
91+
8492 export { resetByDeploy };
8593
8694 /** A fetch's wants and haves; git's own negotiation stays far below this. */
modifiedapps/diffinery/test/e2e/e2e.test.mjs+21−4View file
@@ -25,6 +25,8 @@
2525 const AGENT = resolve(APP, "../../containers/runner/dist/agent.mjs");
2626 const work = mkdtempSync(join(tmpdir(), "diffinery-e2e-"));
2727 let base;
28+/** The shared server's Worker itself, behind wrangler dev's proxy (see startServer). */
29+let direct;
2830 // The dev server writes to a file, not a pipe: a long synchronous git command keeps this process
2931 // from reading a pipe, and a full one (retried webhooks log a stack each) stalls the server mid-push.
3032 const devLogFile = join(work, "wrangler.log");
@@ -183,6 +185,7 @@
183185 const url = `http://127.0.0.1:${port}`;
184186 const persist = join(work, name ? `state-${name}` : "state");
185187 const logFile = name ? join(work, `wrangler-${name}.log`) : devLogFile;
188+ const debugLogFile = join(work, name ? `wrangler-debug-${name}.log` : "wrangler-debug.log");
186189 const log = () => (existsSync(logFile) ? readFileSync(logFile, "utf8") : "");
187190 migratedState(APP, persist);
188191 // Until the web UI exists, run the same app through the API-only entry.
@@ -190,7 +193,7 @@
190193 const out = openSync(logFile, "a");
191194 const child = spawn("pnpm", ["exec", "wrangler", "dev", ...entry, "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, "--var", `PUBLIC_URL:${url}`, "--var", `MASTER_KEY:${randomBytes(32).toString("base64")}`, "--var", "SCHEDULE_TICK_MINUTES:1", "--show-interactive-dev-session=false"], {
192195 cwd: APP,
193- env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" },
196+ env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false", WRANGLER_LOG_PATH: debugLogFile },
194197 stdio: ["ignore", out, out],
195198 detached: true,
196199 });
@@ -208,12 +211,24 @@
208211 process.kill(-child.pid, "SIGTERM");
209212 } catch {}
210213 };
211- return { base: url, token: (await res.json()).token, log, stop };
214+ return { base: url, direct: workerUrl(debugLogFile), token: (await res.json()).token, log, stop };
212215 }
213216
217+/**
218+ * wrangler dev answers on `base` through a proxy Worker of its own, which drops one of several large
219+ * pushes streaming through it at once ("Error inside ProxyWorker ... Network connection lost": the
220+ * push never reaches our Worker; 3 of 5 runs of three pushes, none of 5 sent to the Worker itself).
221+ * Its debug log names the Worker's own address; null if it doesn't.
222+ */
223+function workerUrl(debugLogFile) {
224+ const log = existsSync(debugLogFile) ? readFileSync(debugLogFile, "utf8") : "";
225+ const at = [...log.matchAll(/"userWorkerUrl":\{"protocol":"(https?:)","hostname":"([^"]+)","port":"(\d+)"/g)].pop();
226+ return at ? `${at[1]}//${at[2]}:${at[3]}` : null;
227+}
228+
214229 before(async () => {
215230 assert.ok(existsSync(AGENT), `build the runner first: pnpm --filter @diffinery/runner build (missing ${AGENT})`);
216- ({ base, token } = await startServer());
231+ ({ base, direct, token } = await startServer());
217232 });
218233
219234 after(() => {
@@ -2935,7 +2950,9 @@
29352950 test("large pushes: three large pushes into one repository at once all go in, and a fetch meanwhile works", async () => {
29362951 const me = await ok("GET", "/user");
29372952 await ok("POST", "/user/repos", { name: "pushes-at-once" });
2938- const remote = `${base}/${me.login}/pushes-at-once.git`;
2953+ // Straight to the Worker: wrangler dev's own proxy, not Diffinery, drops pushes streaming through it at once (see workerUrl).
2954+ assert.ok(direct && (await fetch(`${direct}/healthz`)).ok, `the Worker's own address wasn't found in wrangler dev's debug log (${direct})`);
2955+ const remote = `${direct}/${me.login}/pushes-at-once.git`;
29392956 const src = join(work, "pushes-at-once");
29402957 mkdirSync(src, { recursive: true });
29412958 git(src, ["init", "-q", "-b", "main"]);
modifiedapps/diffinery/test/unit/push-retry.test.ts+13−2View file
@@ -54,9 +54,20 @@
5454 expect(f.deleted).toEqual(["push/abc"]);
5555 });
5656
57- it("is not retried for any other error", async () => {
57+ it("is handed over once more after a memory reset too, marked as a retry", async () => {
5858 const f = fakes(1, "Durable Object's isolate exceeded its memory limit and was reset.");
59- await expect(receiveWithRetry(f.env, "r1", f.first, new Uint8Array(4), caller, false)).rejects.toThrow(/memory limit/);
59+ expect(await receiveWithRetry(f.env, "r1", f.first, new Uint8Array(4), caller, false)).toEqual(new Uint8Array([1]));
60+ expect(f.stubs).toEqual([0, 1]);
61+ expect(f.retries).toEqual([false, true]);
62+ // Not a third time.
63+ const g = fakes(2, "Durable Object's isolate exceeded its memory limit and was reset.");
64+ await expect(receiveWithRetry(g.env, "r1", g.first, new Uint8Array(4), caller, false)).rejects.toThrow(/memory limit/);
65+ expect(g.stubs).toEqual([0, 1]);
66+ });
67+
68+ it("is not retried for any other error", async () => {
69+ const f = fakes(1, "Internal error in Durable Object storage write.");
70+ await expect(receiveWithRetry(f.env, "r1", f.first, new Uint8Array(4), caller, false)).rejects.toThrow(/storage write/);
6071 expect(f.stubs).toEqual([0]);
6172 expect(f.deleted).toEqual([]);
6273 expect(resetByDeploy(new Error(RESET))).toBe(true);