Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/resilient-code-host-reads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@kitlangton/stack": patch
---

Retry recognized transient GitHub and GitLab read failures up to twice with jittered exponential backoff, without retrying mutations. Do not retain failed GitLab source-project lookups in the cache. Reuse known GitLab titles, avoid rereading already-titled history, and preserve historical stack entries if optional title enrichment fails.

Drain subprocess stdout and stderr concurrently to prevent hangs when a child fills its stderr pipe before closing stdout.
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ it refuses to overwrite a remote tip changed since sync.
GitHub stack blocks use compact `#101` references. GitLab blocks use `!101`
references plus titles because bare GitLab MR links only show titles on hover.

Recognized transient code-host read failures are retried up to twice, with
jittered backoff around one and two seconds. Writes are not automatically retried:
a timed-out create, merge, or update may already have succeeded. GitLab reuses
known titles and preserves historical stack entries if optional title lookup fails.

If a repair fails, run:

```bash
Expand Down
18 changes: 11 additions & 7 deletions src/platform/proc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,17 @@ export const live = Layer.effect(
.spawn(cmd)
.pipe(Effect.mapError((err) => new ExecError(tool, Array.from(args), 1, String(err))));

const [stdout, stderr, exit] = yield* Effect.all([
text(handle.stdout),
text(handle.stderr),
handle.exitCode.pipe(
Effect.mapError((err) => new ExecError(tool, Array.from(args), 1, String(err))),
),
]);
// Drain both pipes while waiting for exit so a full pipe cannot block the child.
const [stdout, stderr, exit] = yield* Effect.all(
[
text(handle.stdout),
text(handle.stderr),
handle.exitCode.pipe(
Effect.mapError((err) => new ExecError(tool, Array.from(args), 1, String(err))),
),
],
{ concurrency: "unbounded" },
);

const code = Number(exit);
if (!ok.includes(code)) {
Expand Down
16 changes: 15 additions & 1 deletion src/services/CodeHost.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import type { CodeHostError, PullMeta, PullRef } from "../domain/model.ts";
import * as Schedule from "effect/Schedule";
import type { CodeHostError, ExecError, PullMeta, PullRef } from "../domain/model.ts";

export type Provider = "github" | "gitlab";

Expand Down Expand Up @@ -43,6 +44,19 @@ export type AdapterProperties = Pick<

export class Service extends Context.Service<Service, Interface>()("@stack/CodeHost") {}

// Only apply at read call sites: a timed-out write may already have succeeded.
export const retryRead = <A, R>(read: Effect.Effect<A, ExecError, R>) =>
read.pipe(
Effect.retry({
schedule: Schedule.exponential("1 second").pipe(Schedule.jittered),
times: 2,
while: (error) =>
/\b(?:i\/o timeout|TLS handshake timeout|context deadline exceeded|Client\.Timeout exceeded|connection reset by peer|unexpected EOF)\b|\bHTTP[ :]+(?:502|503|504)\b/i.test(
error.stderr,
),
}),
);

export interface RemoteInfo {
readonly host: string;
readonly owner: string;
Expand Down
26 changes: 16 additions & 10 deletions src/services/Stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1467,24 +1467,30 @@ ${note}`;
...new Set(
info
.filter((item): item is PullMeta => item !== null)
.flatMap((item) => StackBlock.references(item.body)),
.flatMap((item) => StackBlock.untitledReferences(item.body)),
),
];
].filter((number) => !metasByNumber.has(number));
const completed = yield* Effect.all(
numbers.map((number) =>
codeHost
.change(number)
.pipe(
Effect.catchTag("CodeHostChangeNotFoundError", () => Effect.succeed(null)),
codeHost.change(number).pipe(
Effect.catchTag("CodeHostChangeNotFoundError", () => Effect.succeed(null)),
Effect.catch(() =>
Effect.logWarning(
`Could not read the title for ${reference(number)}; keeping the existing stack entry.`,
).pipe(Effect.as(null)),
),
),
),
{ concurrency: cfg.codeHostConcurrency },
);
return new Map(
completed
return new Map([
...[...metasByNumber.values()].map(
(item) => [Number(item.number), item.title] as const,
),
...completed
.filter((item): item is PullMeta => item !== null)
.map((item) => [Number(item.number), item.title]),
);
.map((item) => [Number(item.number), item.title] as const),
]);
});
const graph = StackGraph.make({
state,
Expand Down
5 changes: 3 additions & 2 deletions src/services/code-host/GitHub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,14 +126,15 @@ export const layer = Layer.effect(
"--paginate",
"--slurp",
];
const out = yield* run(args);
const out = yield* run(args).pipe(CodeHost.retryRead);
const rows = yield* decodePullList(args, out);
return rows.flatMap((page) => page.map(listRef));
});

const change = Effect.fn("CodeHost.github.change")((pr: number) => {
const args = ["api", `repos/{owner}/{repo}/pulls/${pr}`];
return run(args).pipe(
CodeHost.retryRead,
Effect.catchIf(missingPull, () => Effect.fail(new CodeHostChangeNotFoundError(pr))),
Effect.flatMap((out) => decodePullDetails(args, out)),
Effect.map(meta),
Expand All @@ -155,7 +156,7 @@ export const layer = Layer.effect(
Effect.gen(function* () {
for (;;) {
const args = ["pr", "view", `${pr}`, "--json", "state,mergedAt"];
const out = yield* run(args);
const out = yield* run(args).pipe(CodeHost.retryRead);
const row = yield* decodePullWatch(args, out);

if (row.mergedAt) return;
Expand Down
20 changes: 13 additions & 7 deletions src/services/code-host/GitLab.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import * as Cache from "effect/Cache";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Duration from "effect/Duration";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
import {
Expand Down Expand Up @@ -135,15 +137,18 @@ export const layer = Layer.effect(
return yield* proc.exec(cfg.root, "glab", args, ok);
});

const repositories = yield* Cache.make({
capacity: 256,
lookup: Effect.fn("CodeHost.gitlab.sourceRepository.lookup")(function* (id: number) {
const repositories = yield* Cache.makeWith(
Effect.fn("CodeHost.gitlab.sourceRepository.lookup")(function* (id: number) {
const args = ["api", `projects/${id}`];
const out = yield* run(args);
const out = yield* run(args).pipe(CodeHost.retryRead);
const project = yield* decodeProjectData(args, out);
return project.path_with_namespace;
}),
});
{
capacity: 256,
timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero),
},
);

const sourceRepository = Effect.fn("CodeHost.gitlab.sourceRepository")(function* (
id: number | null,
Expand All @@ -159,7 +164,7 @@ export const layer = Layer.effect(
"--output",
"ndjson",
];
const out = yield* run(args);
const out = yield* run(args).pipe(CodeHost.retryRead);
const rows = yield* decodeMRList(args, out);
return yield* Effect.forEach(
rows,
Expand All @@ -171,6 +176,7 @@ export const layer = Layer.effect(
const change = Effect.fn("CodeHost.gitlab.change")((pr: number) => {
const args = ["mr", "view", `${pr}`, "-F", "json"];
return run(args).pipe(
CodeHost.retryRead,
Effect.catchIf(missingPull, () => Effect.fail(new CodeHostChangeNotFoundError(pr))),
Effect.flatMap((out) => decodeMRView(args, out)),
Effect.flatMap((row) =>
Expand Down Expand Up @@ -206,7 +212,7 @@ export const layer = Layer.effect(
Effect.gen(function* () {
for (;;) {
const args = ["mr", "view", `${pr}`, "-F", "json"];
const out = yield* run(args);
const out = yield* run(args).pipe(CodeHost.retryRead);
const row = yield* decodeMRWatch(args, out);

if (row.merged_at || row.state === "merged") return;
Expand Down
10 changes: 6 additions & 4 deletions src/stackBlock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,14 @@ const completedLines = (
});
};

export const references = (body: string) => {
export const untitledReferences = (body: string) => {
const prior = body.match(new RegExp(`${start}([\\s\\S]*?)${end}`))?.[1];
if (!prior) return [];
return [...new Set([...prior.matchAll(/[#!](\d+)/g)].map((match) => Number(match[1])))]
.filter((number) => Number.isInteger(number))
.sort((a, b) => a - b);
const numbers = prior.split("\n").flatMap((line) => {
const entry = line.match(/^\s*(?:\d+\.|- \[[ x]\])\s+(?:\*\*)?[#!](\d+)(.*)$/);
return entry && !/\s+-\s+\S/.test(entry[2] ?? "") ? [Number(entry[1])] : [];
});
return [...new Set(numbers)].filter((number) => Number.isInteger(number)).sort((a, b) => a - b);
};

export const render = (opts: {
Expand Down
Loading