Skip to content

Fix relayflows shared broker startup - #8

Merged
khaliqgant merged 1 commit into
mainfrom
codex/shared-broker-reuse
Jun 16, 2026
Merged

khaliqgant merged 1 commit into
mainfrom
codex/shared-broker-reuse

Conversation

@khaliqgant

@khaliqgant khaliqgant commented Jun 16, 2026 •

Copy link
Copy Markdown
Member

Summary

  • reuse a healthy persisted broker connection before spawning a new workflow broker
  • serialize only first-start broker creation with a startup lock and per-run leases
  • avoid shutting down a shared broker while other relayflows runs are still active
  • route CLI signal cleanup through the shared broker shutdown path

Verification

  • npm run typecheck --workspace=packages/core
  • npm run test --workspace=packages/core -- src/tests/workflow-runner.test.ts
  • npm run test --workspace=packages/core

Summary by cubic

Ensure Relayflows reuses a healthy shared broker and starts it only once across concurrent runs. Prevents duplicate brokers, flaky startups, and accidental shutdowns while other runs are active.

  • Bug Fixes
    • Reuse persisted broker via connection.json using HarnessDriverClient.connect and a getStatus health check from @agent-relay/harness-driver.
    • Serialize the first boot with a filesystem startup lock and per-run leases to converge concurrent starts on a single spawn.
    • Add ownership/lease tracking and shutdownRelay() so only the owner stops the broker; others disconnect. CLI signals now route through shutdownRelay().
    • Add tests covering shared-broker reuse and concurrent startup convergence.

Written for commit 1227358. Summary will update on new commits.

Review in cubic

@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@khaliqgant, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 7 minutes and 45 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3bdf2798-6735-4953-9ce6-b42a5f084ef0

📥 Commits

Reviewing files that changed from the base of the PR and between 789e502 and 1227358.

📒 Files selected for processing (3)
  • packages/core/src/__tests__/workflow-runner.test.ts
  • packages/core/src/cli.ts
  • packages/core/src/runner.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/shared-broker-reuse

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@khaliqgant
khaliqgant merged commit eae3108 into main Jun 16, 2026
2 checks passed

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces shared broker coordination in WorkflowRunner to allow multiple workflow executions to reuse a single healthy broker connection instead of spawning multiple instances. The feedback highlights several robustness issues in the coordination logic, including a bug in isPidRunning where EPERM errors are incorrectly handled as the process not running, potential lock leaks if writing the lock owner file fails, and race conditions from writing lease and owner files directly instead of using atomic renames.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +259 to +266
function isPidRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

On Unix systems, process.kill(pid, 0) throws an EPERM error if the target process exists but is owned by a different user (or has different privileges). Currently, catching any error and returning false will incorrectly report that such processes are not running. To fix this, explicitly check if the error code is EPERM and return true in that case.

Suggested change
function isPidRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function isPidRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (err) {
return (err as NodeJS.ErrnoException).code === 'EPERM';
}
}

Comment on lines +2011 to +2052
private async acquireSharedBrokerStartLock(
stateDir: string,
startupTimeoutMs: number
): Promise<() => void> {
mkdirSync(stateDir, { recursive: true });
const lockDir = path.join(stateDir, SHARED_BROKER_LOCK_DIRNAME);
const deadline = Date.now() + Math.max(startupTimeoutMs + 5_000, 10_000);
const staleAfterMs = Math.max(startupTimeoutMs * 2, 30_000);

for (;;) {
try {
mkdirSync(lockDir);
writeFileSync(
path.join(lockDir, 'owner.json'),
JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }),
'utf-8'
);
return () => {
rmSync(lockDir, { recursive: true, force: true });
};
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') {
throw err;
}
}

try {
const stat = statSync(lockDir);
if (Date.now() - stat.mtimeMs > staleAfterMs) {
rmSync(lockDir, { recursive: true, force: true });
continue;
}
} catch {
continue;
}

if (Date.now() > deadline) {
throw new Error(`Timed out waiting for shared broker startup lock at ${lockDir}`);
}
await sleepMs(SHARED_BROKER_LOCK_POLL_MS);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There are two issues here:

  1. If writeFileSync fails (e.g., due to disk space or permissions), the newly created lockDir is left behind, causing subsequent runs to block until the lock becomes stale.
  2. The stale lock check only verifies mtimeMs. If a process is still running but slow, deleting its lock can lead to concurrent broker spawns. Checking if the owner PID is still running before deleting the lock makes this much more robust.
  private async acquireSharedBrokerStartLock(
    stateDir: string,
    startupTimeoutMs: number
  ): Promise<() => void> {
    mkdirSync(stateDir, { recursive: true });
    const lockDir = path.join(stateDir, SHARED_BROKER_LOCK_DIRNAME);
    const deadline = Date.now() + Math.max(startupTimeoutMs + 5_000, 10_000);
    const staleAfterMs = Math.max(startupTimeoutMs * 2, 30_000);

    for (;;) {
      try {
        mkdirSync(lockDir);
        try {
          writeFileSync(
            path.join(lockDir, 'owner.json'),
            JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }),
            'utf-8'
          );
        } catch (writeErr) {
          rmSync(lockDir, { recursive: true, force: true });
          throw writeErr;
        }
        return () => {
          rmSync(lockDir, { recursive: true, force: true });
        };
      } catch (err) {
        if ((err as NodeJS.ErrnoException).code !== 'EEXIST') {
          throw err;
        }
      }

      try {
        const ownerPath = path.join(lockDir, 'owner.json');
        let isStale = false;
        try {
          const owner = JSON.parse(readFileSync(ownerPath, 'utf-8'));
          if (typeof owner.pid === 'number' && !isPidRunning(owner.pid)) {
            isStale = true;
          }
        } catch {
          const stat = statSync(lockDir);
          if (Date.now() - stat.mtimeMs > staleAfterMs) {
            isStale = true;
          }
        }
        if (isStale) {
          rmSync(lockDir, { recursive: true, force: true });
          continue;
        }
      } catch {
        continue;
      }

      if (Date.now() > deadline) {
        throw new Error(`Timed out waiting for shared broker startup lock at ${lockDir}`);
      }
      await sleepMs(SHARED_BROKER_LOCK_POLL_MS);
    }
  }

Comment on lines +2054 to +2078
private createSharedBrokerLease(
stateDir: string,
connectionPath: string,
runId: string,
startedBroker: boolean
): SharedBrokerLease {
const leaseDir = path.join(stateDir, SHARED_BROKER_LEASE_DIRNAME);
const ownerPath = path.join(stateDir, SHARED_BROKER_OWNER_FILENAME);
mkdirSync(leaseDir, { recursive: true });
const leasePath = path.join(
leaseDir,
`${process.pid}-${runId}-${randomBytes(4).toString('hex')}.json`
);
writeFileSync(
leasePath,
JSON.stringify({
pid: process.pid,
runId,
startedBroker,
createdAt: new Date().toISOString(),
}),
'utf-8'
);
return { stateDir, connectionPath, ownerPath, leasePath, startedBroker };
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Writing the lease file directly to its final path can cause concurrent processes calling countLiveSharedBrokerLeases to read a partially written or empty file, leading to JSON parsing errors and accidental deletion of the lease. Writing to a temporary file and atomically renaming it using renameSync prevents this race condition.

  private createSharedBrokerLease(
    stateDir: string,
    connectionPath: string,
    runId: string,
    startedBroker: boolean
  ): SharedBrokerLease {
    const leaseDir = path.join(stateDir, SHARED_BROKER_LEASE_DIRNAME);
    const ownerPath = path.join(stateDir, SHARED_BROKER_OWNER_FILENAME);
    mkdirSync(leaseDir, { recursive: true });
    const leasePath = path.join(
      leaseDir,
      `${process.pid}-${runId}-${randomBytes(4).toString('hex')}.json`
    );
    const tempPath = `${leasePath}.tmp`;
    writeFileSync(
      tempPath,
      JSON.stringify({
        pid: process.pid,
        runId,
        startedBroker,
        createdAt: new Date().toISOString(),
      }),
      'utf-8'
    );
    renameSync(tempPath, leasePath);
    return { stateDir, connectionPath, ownerPath, leasePath, startedBroker };
  }

Comment on lines +2080 to +2091
private writeSharedBrokerOwner(lease: SharedBrokerLease): void {
const conn = readBrokerConnectionFile(lease.connectionPath);
writeFileSync(
lease.ownerPath,
JSON.stringify({
pid: conn?.pid,
createdByPid: process.pid,
createdAt: new Date().toISOString(),
}),
'utf-8'
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Writing the owner file directly can cause concurrent processes calling isWorkflowOwnedSharedBroker to read a partially written file, leading to JSON parsing errors. Writing to a temporary file and atomically renaming it using renameSync ensures thread-safe/process-safe visibility.

  private writeSharedBrokerOwner(lease: SharedBrokerLease): void {
    const conn = readBrokerConnectionFile(lease.connectionPath);
    const tempPath = `${lease.ownerPath}.tmp`;
    writeFileSync(
      tempPath,
      JSON.stringify({
        pid: conn?.pid,
        createdByPid: process.pid,
        createdAt: new Date().toISOString(),
      }),
      'utf-8'
    );
    renameSync(tempPath, lease.ownerPath);
  }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant