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.

Stop a step's process that starts after the job was stopped

A cancel that landed between one step ending and the next one's process starting left that
process running. CI's slower host hit this in the second-cancel test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155wm8JcL2djVkP8kBaRGmo
test-bot authored signed1 parent 4bbc495commit 9466de61bd650dabda9e7a1639bd55e59218605cBrowse files
2 files changed+27−10
modifiedcontainers/runner/src/job.ts+9−8View file
@@ -217,6 +217,13 @@
217217 for (const c of this.children) killTree(c);
218218 }
219219
220+ /** A process the job started: stopped with the job, or at once when it started after the stop. */
221+ private track(c: ChildProcess): void {
222+ this.children.add(c);
223+ c.once("close", () => this.children.delete(c));
224+ if (this.halting) killTree(c);
225+ }
226+
220227 /** Once the cancelled step has stopped: let the steps that still run after a cancel do their work. */
221228 private resumeAfterCancel(): void {
222229 if (!this.halting || this.forced) return;
@@ -724,10 +731,7 @@
724731 env,
725732 timeoutMs: o.timeoutMs,
726733 onLine: (l) => proc.handle(l),
727- onSpawn: (c) => {
728- this.children.add(c);
729- c.once("close", () => this.children.delete(c));
730- },
734+ onSpawn: (c) => this.track(c),
731735 });
732736 await this.applyFileCommands(files, o.io);
733737 if (res.signal && this.replacing && !this.stopping) {
@@ -809,10 +813,7 @@
809813 capture: o.capture,
810814 input: o.input,
811815 onLine: o.silent ? undefined : (l) => this.logger.line(o.io.logIndex, l),
812- onSpawn: (c) => {
813- this.children.add(c);
814- c.once("close", () => this.children.delete(c));
815- },
816+ onSpawn: (c) => this.track(c),
816817 });
817818 }
818819
modifiedcontainers/runner/test/job.test.ts+18−2View file
@@ -521,16 +521,32 @@
521521 const t0 = Date.now();
522522 while (!api.logText(0).includes("started") && Date.now() - t0 < 10_000) await new Promise((r) => setTimeout(r, 50));
523523 runner!.cancel();
524- while (!api.logText(1).includes("cleanup-started") && Date.now() - t0 < 10_000) await new Promise((r) => setTimeout(r, 50));
524+ const t1 = Date.now();
525+ while (!api.logText(1).includes("cleanup-started") && Date.now() - t1 < 15_000) await new Promise((r) => setTimeout(r, 50));
526+ expect(api.logText(1)).toContain("cleanup-started");
525527 runner!.cancel();
526528 const c = await p;
527529 expect(c.conclusion).toBe("cancelled");
528530 expect(api.lastReport(1)?.conclusion).toBe("cancelled");
529531 expect(api.lastReport(2)?.conclusion).toBe("cancelled");
530532 expect(api.logText(2)).not.toContain("never");
531- expect(Date.now() - t0).toBeLessThan(15_000);
533+ expect(Date.now() - t1).toBeLessThan(15_000);
532534 });
533535
536+ it("a process a step starts after the job was stopped is stopped at once", async () => {
537+ let runner: JobRunner | undefined;
538+ const p = run(makeSpec(api, [{ run: "echo started; sleep 60" }, { if: "always()", run: "echo cleanup-started; sleep 60" }]), { onRunner: (r) => (runner = r) });
539+ const t0 = Date.now();
540+ while (!api.logText(0).includes("started") && Date.now() - t0 < 10_000) await new Promise((r) => setTimeout(r, 50));
541+ // Both cancels before the cleanup step's process starts: it must not run its 60 seconds.
542+ runner!.cancel();
543+ runner!.cancel();
544+ const c = await p;
545+ expect(c.conclusion).toBe("cancelled");
546+ expect(api.lastReport(1)?.conclusion).toBe("cancelled");
547+ expect(Date.now() - t0).toBeLessThan(20_000);
548+ });
549+
534550 it("stops the steps that run after a cancel once their grace runs out", async () => {
535551 let runner: JobRunner | undefined;
536552 const p = run(makeSpec(api, [{ run: "echo started; sleep 60" }, { if: "always()", run: "echo cleanup-started; sleep 60" }, { if: "always()", run: "echo never" }]), { onRunner: (r) => (runner = r) });