Skip to content

services updates#1422

Merged
alexcos20 merged 10 commits into
next-4from
bug/raceloop_services
Jul 17, 2026
Merged

services updates#1422
alexcos20 merged 10 commits into
next-4from
bug/raceloop_services

Conversation

@alexcos20

@alexcos20 alexcos20 commented Jul 15, 2026

Copy link
Copy Markdown
Member

Fix: service restart/stop race with the InternalLoop (follow-up to #1416)

Problem

Service restarts still failed on live nodes after #1416, always with:

restartService <id> failed: (HTTP code 404) no such container -
failed to set up container networking: network ocean-svc-<id> not found

Live-node logs showed the smoking gun — the loop's orphan recovery ran for the very
service being restarted
, seconds before the failure:

16:30:11.984  Performing P2P task: serviceRestart a6aba923…
16:30:16.200  ERROR processServiceStart: orphaned service a6aba923… in state "PullImage" — cleaning up
16:30:18.375  ERROR restartService a6aba923… failed: … network ocean-svc-a6aba923… not found

Root cause

A race between restartService() and the engine's InternalLoop (2 s tick):

  1. restartService() runs synchronously from the handler and persists intermediate
    statuses (StartingPullImage/BuildImage) while it pulls the image.
  2. Every tick, the loop fetches all jobs in pending-start statuses
    (getPendingServiceStarts) and launches processServiceStart() for any serviceId not
    in the in-memory guard set — which only ever covered loop-launched starts.
    restartService() never registered itself.
  3. The loop picked up the mid-restart job; status PullImageStarting sent it into
    the orphan-recovery branch (crash cleanup), which removes the service network by
    its deterministic name ocean-svc-<serviceId>, releases the host ports and flips the
    job to Error.
  4. The restart then created its container against the now-deleted network →
    container.start() → 404 "network not found".

The race predates #1416, but was silent (clobbered status / double-released ports).
#1416's removal-by-name made it deterministic: any restart whose image pull spans a
2-second tick lost the race — i.e. essentially every restart.

stopService() had the same family of races in both directions (a stop tearing down an
in-flight start/restart's fresh network; a restart racing an in-flight stop or the expiry
sweep).

Fix

src/components/c2d/compute_engine_docker.ts:

  • Generalized the loop's guard set into a per-service lifecycle lock
    (servicesBeingStartedserviceOpsInFlight): at most one lifecycle operation — the
    loop-driven start pipeline, restartService, or stopService — runs per service.
  • restartService() and stopService() acquire the lock at entry
    (acquireServiceLifecycleLock) and release it in finally. If the lock is held they
    throw Service <id> has a start/stop/restart operation in progress — retry shortly;
    both handlers already map throws to an HTTP error, so concurrent/duplicate requests get
    a clear rejection instead of corrupting each other.
  • The InternalLoop keeps its exact skip-if-locked behavior for starts; its expiry
    sweep
    no longer marks a service Expired when the stop was deferred by the lock
    (that would leak the container/ports) — it retries on the next tick instead.
  • Crash recovery is unaffected: after a node dies mid-start/mid-restart, the lock set is
    empty at boot, so the loop still orphan-cleans stale PullImage-state jobs.
  • Engine stop() now drains handler-driven ops too (review finding): the promise set
    the loop already used for its fire-and-forget starts was generalized
    (serviceStartPromisesserviceOpPromises) and restartService/stopService
    register themselves in it, so stop() returns only once no lifecycle op is still
    touching Docker/escrow on the shared DB.
  • Health check vs restart hardening (review finding): checkRunningServices skips
    services with a lifecycle op in flight (a restart intentionally kills the container —
    not an "unexpected death"), and markServiceFailed bails when the job's containerId
    changed since the health snapshot (a completed restart replaced the container), so a
    healthy restarted service can't be flagged Error by a stale check.

docs/services.md: documented the "lifecycle operations are exclusive per service"
behavior and the retry semantics.

Tests

  • New unit suite src/test/unit/service/serviceRestartRace.test.ts (7 tests):
    • lock held during a restart's image pull and released on success and failure paths
    • concurrent restart/stop rejected without touching Docker or the DB
    • restart-during-stop rejected; stop releases the lock afterwards
    • regression: the InternalLoop skips a locked service (with a control asserting it
      would run orphan recovery when unlocked — the pre-fix behavior)
    • the expiry sweep defers a locked service instead of marking it Expired sans teardown
    • engine stop() drains an in-flight handler-driven stopService before returning
  • New integration test (l3) in src/test/integration/services.test.ts: reproduces
    the live-node scenario end-to-end — delays pullImageRef by 5 s so real InternalLoop
    ticks land mid-restart, asserts concurrent SERVICE_STOP / SERVICE_RESTART are rejected
    meanwhile, and that the restart completes to Running on the same host port with the
    service answering HTTP. Fails pre-fix exactly like the live node.
  • Existing engine-stub tests (compute.test.ts, serviceNetworkCleanup.test.ts) seeded
    with the new lock field (their Object.create(prototype) engines skip field
    initializers).

Review findings addressed / rejected

  • Addressed: engine stop() not draining direct restartService/stopService calls
    (see Fix above).
  • Rejected (twice): reordering doRestartService to persist Starting + cleared
    containerId/networkId before tearing down the old container/network. That stretches
    the crash window in which the DB holds a bare Starting record from milliseconds (two
    consecutive DB writes) to 10+ seconds of Docker teardown I/O. On reboot the loop treats
    Starting as a brand-new start and runs the full pipeline — creating and claiming a
    second escrow lock (double-charging the consumer), allocating new host ports
    (silently changing the consumer's endpoints), and — since the crash was mid-teardown —
    potentially leaving the old container running as an unpaid zombie no longer
    referenced by the job record. The current order crashes into a benign state instead
    (job still Running with stale ids → the pre-existing boot health check flips it to
    Error → the consumer re-issues restart, whose teardown handles the missing resources
    via benign 404s on the same ports/payment — no reliance on the new health-check
    guards). The ordering constraint is now documented in a comment at the teardown site.
    The reviewer's underlying concern (the health check racing a restart) is closed by the
    targeted checkRunningServices/markServiceFailed hardening instead.

Verification

  • npm run type-check clean, npm run lint green.
  • All service + compute unit suites pass (205 tests).
  • Integration suite (npm run test:servicesintegration) requires Barge — validated in CI.

Summary by CodeRabbit

  • New Features
    • Added Restarting as a first-class service status and expanded lifecycle state handling for pending/restart scenarios.
    • Introduced cross-process per-service lifecycle locking (with stale-lock stealing) to prevent overlapping start/stop/restart/expiry operations.
  • Bug Fixes
    • Improved reservation/host-port release behavior: ports/endpoints stay reserved across stop/restart and only release after expiry or successful teardown.
    • Hardened Docker/network lifecycle and orphan/unexpected-death handling; refined restart/stop/expiry race handling.
    • Added best-effort escrow funds pre-check for start (fast “Insufficient escrow funds” failures).
  • Documentation
    • Updated service-on-demand lifecycle docs with clarified async semantics and polling expectations.
  • Tests
    • Added restart/stop race coverage and expanded unit/integration assertions for locking, reservations, and state transitions.

@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Service-on-demand lifecycle handling now supports asynchronous restart, explicit Restarting status, per-service in-memory and SQLite lease locking, reservation retention through stop/restart, deferred expiry cleanup, shared service lookup, escrow pre-checks, and expanded race-condition coverage.

Changes

Service lifecycle

Layer / File(s) Summary
Lifecycle status and lease storage
src/@types/C2D/ServiceOnDemand.ts, src/components/database/..., docs/services.md
Adds Restarting, SQLite-backed service leases, updated reservation queries, and documentation for asynchronous restart, retained reservations, failure states, and exclusivity.
Locked lifecycle orchestration
src/components/c2d/compute_engine_*.ts, src/components/core/service/utils.ts
Coordinates lifecycle operations under per-service locks, drains operations during shutdown, defers expiry cleanup, preserves ports, and hardens Docker network recovery.
Shared lookup and payment-aware command handling
src/components/core/service/*
Centralizes job-and-engine resolution, separates missing-engine errors, serializes extension updates, and adds best-effort start escrow validation.
Lifecycle race and reservation validation
src/test/unit/service/*, src/test/unit/compute.test.ts, src/test/integration/services.test.ts
Covers asynchronous restart, lock contention, shutdown draining, stale snapshots, expiry retries, payment states, cleanup failures, and endpoint preservation.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ServiceRestartHandler
  participant C2DEngineDocker
  participant C2DDatabase
  participant Docker
  Client->>ServiceRestartHandler: SERVICE_RESTART
  ServiceRestartHandler->>C2DEngineDocker: resolve and restart service
  C2DEngineDocker->>C2DDatabase: acquire lease and persist Restarting
  C2DEngineDocker->>Docker: pull image and replace container
  Docker-->>C2DEngineDocker: replacement container
  C2DEngineDocker->>C2DDatabase: persist Running and release lease
  Client->>ServiceRestartHandler: poll status
  ServiceRestartHandler-->>Client: current service status
Loading

Possibly related PRs

Suggested reviewers: bogdanfazakas, andreip136

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 46.15% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title is too generic and does not communicate the main service lifecycle changes in the pull request. Use a concise, specific title such as "Add service lifecycle locking and restart semantics".
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch bug/raceloop_services

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.

@alexcos20

Copy link
Copy Markdown
Member Author

/run-security-scan

@alexcos20 alexcos20 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

AI automated code review (Gemini 3).

Overall risk: low

Summary:
This PR effectively addresses race conditions in service lifecycle management by introducing an in-memory per-service lifecycle lock (serviceOpsInFlight). The implementation accurately utilizes synchronous lock acquisition (safe in Node.js event loop) and robust try...finally blocks to guarantee lock release. The background loop now correctly respects these locks, preventing destructive operations like orphan-recovery from tearing down networks mid-creation. The inclusion of thorough unit and integration tests is highly commendable. LGTM!

Comments:
• [INFO][style] Excellent implementation of the per-service lifecycle lock. Using a synchronous check (has) and insertion (add) ensures there are no asynchronous yields where a race condition could slip through.
• [INFO][bug] Good use of the try...finally pattern. This guarantees that the lock is always released, even if the underlying Docker API throws an unexpected error, avoiding a permanently locked state for the service.
• [INFO][bug] Catching the error here and continuing the loop is a great defensive practice. It ensures that an error in stopping a single locked/expired service does not abort the entire expiry sweep for other services.
• [INFO][other] Great approach to testing the race condition. Intercepting pullImageRef to force a targeted delay (await sleep(5000)) reliably reproduces the state necessary to test this async bug without introducing excessive test flakiness.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/components/c2d/compute_engine_docker.ts (1)

3787-3816: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Ordering race in doRestartService: old container is torn down before the DB status is flipped away from Running, letting a concurrent health-check flip it to Error.

doRestartService stops/removes the old container and network (lines 3804-3810) before persisting job.status = Starting (lines 3812-3816). doStopService does the opposite — it persists Stopping first, then touches Docker. During that window in restartService, checkRunningServices()/checkServiceContainerHealth() (run every InternalLoop tick, not gated by serviceOpsInFlight) can see the job as Running in the DB while its container is already gone, conclude "container lost", and call markServiceFailed → persists Error. With a 2s cron tick and up to a 10s container-stop timeout, this window is realistically reachable in production.

This is a real race, though it self-heals: doRestartService keeps mutating its own in-memory job and unconditionally overwrites the DB on each subsequent step, so the stray Error write gets clobbered once the restart proceeds. The visible impact is a transient false Error status to a client polling serviceStatus mid-restart — undermining the exact class of race this PR sets out to close.

Mirror doStopService's ordering: persist the status flip away from Running before touching Docker.

🔧 Proposed fix: flip status before tearing down
-    // 1. Tear down existing container + network (best-effort)
-    if (job.containerId) {
-      const c = this.docker.getContainer(job.containerId)
-      await c.stop({ t: 10 }).catch(() => {})
-      await c.remove({ force: true }).catch(() => {})
-    }
-    await this.removeServiceNetwork(serviceId, job.networkId).catch(() => {})
-
-    job.status = ServiceStatusNumber.Starting
-    job.statusText = ServiceStatusText[ServiceStatusNumber.Starting]
-    job.containerId = ''
-    job.networkId = ''
-    await this.db.updateServiceJob(job)
+    // Flip status away from Running (and persist) BEFORE touching docker, so a concurrent
+    // InternalLoop tick's checkRunningServices() can no longer see this service as "Running"
+    // while its container is mid-teardown.
+    const oldContainerId = job.containerId
+    const oldNetworkId = job.networkId
+    job.status = ServiceStatusNumber.Starting
+    job.statusText = ServiceStatusText[ServiceStatusNumber.Starting]
+    job.containerId = ''
+    job.networkId = ''
+    await this.db.updateServiceJob(job)
+
+    // 1. Tear down the previous container + network (best-effort)
+    if (oldContainerId) {
+      const c = this.docker.getContainer(oldContainerId)
+      await c.stop({ t: 10 }).catch(() => {})
+      await c.remove({ force: true }).catch(() => {})
+    }
+    await this.removeServiceNetwork(serviceId, oldNetworkId).catch(() => {})

As defense-in-depth, checkRunningServices() could additionally skip serviceIds present in serviceOpsInFlight, matching the guard already applied to the pendingStarts and expiry loops.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/c2d/compute_engine_docker.ts` around lines 3787 - 3816, Update
doRestartService to set the job status to Starting, clear its containerId and
networkId, and persist the job with updateServiceJob before stopping or removing
the existing container and network. Preserve the existing expiry validation and
teardown behavior, and do not rely on the optional checkRunningServices
defense-in-depth change.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/c2d/compute_engine_docker.ts`:
- Around line 3697-3707: Update the lifecycle shutdown flow in stop() to track
and await in-flight direct stopService and restartService calls, in addition to
internalLoopPromise and serviceStartPromises. Use the existing service lifecycle
operation tracking around stopService and its corresponding restartService path,
and ensure stop() drains these promises before returning so no service teardown
or restart remains active.

---

Outside diff comments:
In `@src/components/c2d/compute_engine_docker.ts`:
- Around line 3787-3816: Update doRestartService to set the job status to
Starting, clear its containerId and networkId, and persist the job with
updateServiceJob before stopping or removing the existing container and network.
Preserve the existing expiry validation and teardown behavior, and do not rely
on the optional checkRunningServices defense-in-depth change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 97c7e03d-6f72-4265-98a6-a267b36e9d5c

📥 Commits

Reviewing files that changed from the base of the PR and between 7bcf7f0 and 01e95c6.

📒 Files selected for processing (6)
  • docs/services.md
  • src/components/c2d/compute_engine_docker.ts
  • src/test/integration/services.test.ts
  • src/test/unit/compute.test.ts
  • src/test/unit/service/serviceNetworkCleanup.test.ts
  • src/test/unit/service/serviceRestartRace.test.ts

Comment thread src/components/c2d/compute_engine_docker.ts
@alexcos20

Copy link
Copy Markdown
Member Author

Commit 50af212 description:
Closes #1420 and #1421

Service lifecycle hardening: cross-process locking, async restart, paid-window reservations

Why

The network ocean-svc-<id> not found restart failure reappeared on a live node already
running the lifecycle-lock fix. The in-memory serviceOpsInFlight lock serializes
operations within one process, but it cannot see a second node process sharing the same
databases/ directory and Docker daemon (stale container during a redeploy, overlapping
process-manager restart, …): the other process's orphan-recovery deletes the network by its
deterministic name exactly between the restart's createNetwork and container.start().
While closing that, this change also fixes the resource-reservation model (a consumer pays
for a time window, not for a running container), makes restart non-blocking, and adds
enough DEBUG diagnostics to reconstruct any future lifecycle failure from remote logs alone.

1. Cross-process lifecycle lease (service_locks)

New SQLite table service_locks (sqliteCompute.ts, wrapped by C2DDatabase). Every
exclusive lifecycle operation — the loop's start pipeline, restart, stop, expiry teardown —
now takes a DB lease alongside the in-memory lock:

  • Atomic acquisition via a single upsert: insert a fresh row, or steal one whose
    acquiredAt is older than 2 minutes (crashed holder). Two processes racing it can never
    both see success.
  • Heartbeat: a 30 s interval re-stamps every lease the instance holds, so multi-minute
    image pulls/builds are never stolen as stale. Release is holder-scoped (a steal victim
    cannot delete the new owner's row); a crashed process's rows self-expire — no manual
    cleanup ever.
  • Graceful degradation: if the lock DB call itself fails, the engine falls back to
    in-process-only locking (the previous behavior) instead of bricking single-process nodes.
  • The container health check consults the lease too (isServiceLocked), so another
    process's mid-restart teardown is no longer misread as "container died" → Error.
  • A stopped engine instance (config push / engine rebuild) refuses new lifecycle work —
    its replacement may already be running against the same DB and daemon.

2. SERVICE_RESTART is now asynchronous (new Restarting (45) status)

The handler used to block until the restart finished — including a potentially multi-minute
image pull — long enough to time out the HTTP/P2P response. It now mirrors SERVICE_START:
fast validations (exists, owner, not expired, payment not refunded), persist the job as
Restarting (45), respond immediately; teardown → re-pull/rebuild → new container run
in the background under the same lease. Clients poll serviceStatus and watch
Restarting → PullImage/BuildImage → Running (or Error + reason in statusText).

Side benefits:

  • Restarting is part of the pending-status set (single source of truth:
    SERVICE_START_PENDING_STATUSES in @types/C2D/ServiceOnDemand.ts), so a crash
    mid-restart is orphan-recovered at boot exactly like a crash mid-start — this also
    removes the old hazard where a bare Starting record persisted mid-restart could be
    double-started with a second escrow lock.
  • New guard: a service whose start payment was refunded (lock cancelled, never claimed)
    cannot be restarted — it was never paid for.
  • Compat note: the restart response now returns the job in Restarting, not Running.
    Clients that treated the response as "already running" must poll serviceStatus.

3. Reservations last the whole paid window — SERVICE_STOP keeps them

Previously an explicit stop released the resource amounts and host ports, so another
consumer could take a stopped service's GPU/port mid-window and block the restart. Now:

  • Stopped counts as an active (resource-holding) status in getRunningServiceJobs,
    which feeds getUsedResourcescheckIfResourcesAreAvailable — a stopped service's
    cpu/ram/gpu still gate both new service starts and compute jobs.
  • Stop tears down the container + network but keeps the host ports reserved; a restart
    resumes on the same endpoints. Boot-time port re-seeding follows the same query, so the
    reservation survives node restarts. (CPU pinning is still freed at stop and re-derived
    at restart — safe, because the amount gate holds the capacity.)
  • Error keeps its ports now too (paid jobs); previously two failure paths released them.
    Only never-paid (refunded) failures free their ports immediately.

4. Expired is the only release point — and it's enforced

  • The expiry sweep now also covers Stopped (previously a stopped service sat at Stopped
    forever past expiresAt, still reading as restartable). Sweep order: teardown → mark
    Expired → release ports; amounts stop counting because Expired is not active.
  • The sweep refuses to mark Expired while teardown failed (job comes back
    Error "stop failed: …"). Expired is terminal and never swept again, so stamping it
    over a live container would have leaked the container + ports forever; instead the job
    stays in the expirable set and is retried every tick until Docker recovers.

5. Orphan-recovery staleness guard

processServiceStart re-reads the job from the DB after acquiring the lease and bails
unless it is still in a pending status. The loop's pendingStarts snapshot is taken before
the lock, so a row captured mid-restart could previously be processed after that restart
completed — tearing down the freshly created container/network and clobbering
Running → Error.

6. Service handlers resolve the owning engine by clusterHash

restart/stop/extend/getStreamableLogs picked the first engine whose (shared) DB returned
the job — wrong on nodes with several Docker engines, where the first engine's lock and
loop don't protect another engine's job. New shared helper findServiceJobAndEngine()
resolves by the job's clusterHash and returns an explicit error when no configured engine
owns it (config drift).

7. DEBUG diagnostics for remote troubleshooting

  • logServiceDockerState(): compact docker ps -a + all ocean-svc-* networks, dumped
    before/after restart and stop teardown, at orphan-recovery entry, on createNetwork
    409 conflicts, and on every start/stop/restart failure right before cleanup — a
    remote log now shows exactly what the daemon looked like when container.start() failed.
  • Step-level DEBUG lines across the lifecycle: restart accepted (old ids + expiry), old
    container stop/remove results, per-ref network removal outcome (found / attached
    containers / removed / already gone), network + container created/started, lease
    acquired/released/held-elsewhere (with holder id), stale-snapshot skips, expiry
    "marked Expired — all resources released", INFO on completed start/restart with bound
    host ports.

Tests

  • serviceRestartRace.test.ts (+225 lines): async-restart contract (immediate Restarting,
    lock held until background op settles, Error persisted on failure), refunded-restart
    rejection, DB-lease conflict/steal/holder-scoped release/heartbeat, InternalLoop skipping
    leased jobs, stale-snapshot no-op, stopped-engine rejection, the user-B scenario
    (stop → allocator refuses the port → expiry → port free again), expiry-sweep
    teardown-failure retry, Stopped→Expired without touching docker.
  • serviceJobsDatabase.test.ts: lease semantics on real SQLite; Stopped active /
    Restarting active + pending; Stopped expirable; accounting clean-slate switched to
    Expired (the only truly-released status).
  • compute.test.ts: restart tests reworked to the async contract; failed-start port
    retention proven via the allocator.
  • Integration services.test.ts: all 17 pass against Barge + real Docker, including the
    mid-restart orphan-recovery race (l3) and the full expiry window (n); stale-job cleanup
    switched to Expired.
  • docs/services.md updated: async restart, reservation-for-the-paid-window contract,
    expiry as sole release point, cross-process lease semantics.

@alexcos20

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.

@alexcos20

Copy link
Copy Markdown
Member Author

What was fixed (insufficient-funds follow-up) in b3f38e1

1. Free-restart vulnerability (the serious one). The restart guard in
compute_engine_docker.ts now requires a claimed payment:
if (!job.payment?.claimTx) reject. Every legitimately restartable job has a claimTx
(claiming happens before the first container start), so this closes the vector where a
no-funds start fails at createLock with all payment fields empty and a follow-up
SERVICE_RESTART would have run the container for free. Rejection message names the
cause: "payment was never claimed (unpaid or refunded) — start a new service".

2. Unpaid resource squatting. getRunningServiceJobs (sqliteCompute.ts) now filters
out Error/Stopped jobs without a claimTx — a never-paid or refunded job no longer
reserves cpu/ram/gpu or gets its ports re-seeded at boot. Mid-pipeline statuses
(StartingClaiming) still reserve, since they're en route to payment. So the
reservation contract is now precisely: paid jobs hold their resources for the whole
window; unpaid ones hold nothing.

3. Fail-fast funds check. SERVICE_START (startService.ts) now compares the
consumer's available escrow against the server-side cost before persisting anything,
returning 400 Insufficient escrow funds: available X, required Y wei instead of a
200 + serviceId that dies silently at the Locking step. It's deliberately best-effort:
an RPC failure skips the pre-check (logged at DEBUG) and the background createLock
remains the authoritative gate — chain hiccups can't block starts.

New tests: never-paid restart rejection, unpaid-Error/refunded-Stopped excluded from
the active set (real SQLite), insufficient-funds start → 400 with no job record created,
and RPC-failure-doesn't-block-start. Docs updated with all three.

The end-to-end behavior for a user without funds is now: an immediate 400 in the normal
case; if the balance changes between request and lock, the job lands in Error with the
funds message in statusText, reserves nothing, and cannot be restarted into a free ride.

@coderabbitai coderabbitai 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.

Actionable comments posted: 10

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/c2d/compute_engine_docker.ts`:
- Around line 3864-3878: Update tryAcquireServiceLifecycleLock to recheck
this.stopped immediately after the asynchronous db.acquireServiceLock resolves;
if shutdown occurred, release the acquired service lock and return false, while
preserving the existing in-flight cleanup behavior.
- Around line 3846-3848: Update the lock checks around isServiceLocked in the
affected lifecycle paths to fail closed: do not convert database errors to
false/unlocked. When lease state cannot be verified, skip or reject the
restart/stop operation, preserving normal behavior only when the database check
succeeds; apply this consistently to both referenced paths.
- Around line 1847-1885: Update the expiry sweep around stopService and the
ServiceExtendHandler flow to use the same lifecycle lock/CAS contract,
serializing expiry with SERVICE_EXTEND. Re-read the current expiresAt while
holding the lease immediately before teardown and skip expiry if the service was
extended. Ensure the extension’s final ServiceJob write cannot overwrite a newer
stop/restart/Expired state, and preserve the existing teardown and port-release
behavior only for a valid expiry transition.
- Around line 4037-4040: Update the missing-job branch in the restartService
flow to release the lifecycle lock and DB lease before returning null. Ensure
the cleanup used by the surrounding catch/finally path is invoked explicitly or
restructure control flow so the background operation’s finally executes, while
preserving the existing debug log and null result.
- Around line 1831-1838: Update the fire-and-forget promise created around
processServiceStart in the service-start loop to attach a rejection handler,
while preserving the existing finally cleanup that deletes startPromise and
releases the lifecycle lock. Ensure start failures are consumed or routed
through the established error-handling path so the tracked promise cannot
produce an unhandled rejection.

In `@src/components/database/sqliteCompute.ts`:
- Around line 187-215: Update acquireServiceLock and its callers to return and
propagate a fencing generation/token rather than only a boolean. Require the
current holder to validate that token immediately before destructive or
state-changing Docker/job operations, and abort when validation fails after a
stale takeover; ensure release remains holder-scoped and cannot bypass fencing.
- Around line 418-426: Update the expirableStatuses definition near the existing
Running, Error, and Stopped entries to also include
ServiceStatusNumber.Stopping, ensuring crashed jobs persisted in Stopping are
recovered by the same expiry sweep.

In `@src/test/integration/services.test.ts`:
- Around line 786-823: Update the mid-restart wait in the SERVICE_RESTART test
around restartPromise and the PullImage polling loop to assert that the job
reaches ServiceStatusNumber.PullImage before proceeding, rather than silently
timing out. Also synchronize with and assert that an InternalLoop tick occurs
while the job remains in PullImage, then issue the concurrent commands only
after both conditions are confirmed.

In `@src/test/unit/service/serviceRestartRace.test.ts`:
- Around line 364-384: Ensure host-port reservations are always released via
finally blocks. In src/test/unit/service/serviceRestartRace.test.ts lines
364-384, wrap all work after reserveHostPort(PORT) in try/finally and release
the port in finally; in src/test/unit/compute.test.ts lines 1637-1643, move
releaseHostPort(reservedPort) into finally while preserving the existing test
assertions and cleanup behavior.
- Around line 144-156: Update the restartService failure test to assert that
updateServiceJob receives the Error status and pull failure text, rather than
relying only on the mutable job object. In
src/test/unit/service/serviceRestartRace.test.ts lines 144-156, add that
persistence assertion; in src/test/unit/compute.test.ts lines 1747-1755,
1794-1807, 1820-1827, and 1840-1847, assert that each restart failure or
replacement/reused/cleared command and entrypoint value is passed through the
database persistence call.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 24a0a66d-5f88-451e-b2a1-beaa67fe31b9

📥 Commits

Reviewing files that changed from the base of the PR and between 7bcf7f0 and 50af212.

📒 Files selected for processing (16)
  • docs/services.md
  • src/@types/C2D/ServiceOnDemand.ts
  • src/components/c2d/compute_engine_docker.ts
  • src/components/core/service/extendService.ts
  • src/components/core/service/getStreamableLogs.ts
  • src/components/core/service/restartService.ts
  • src/components/core/service/stopService.ts
  • src/components/core/service/utils.ts
  • src/components/database/C2DDatabase.ts
  • src/components/database/sqliteCompute.ts
  • src/test/integration/services.test.ts
  • src/test/unit/compute.test.ts
  • src/test/unit/service/serviceHandlers.test.ts
  • src/test/unit/service/serviceJobsDatabase.test.ts
  • src/test/unit/service/serviceNetworkCleanup.test.ts
  • src/test/unit/service/serviceRestartRace.test.ts

Comment thread src/components/c2d/compute_engine_docker.ts
Comment thread src/components/c2d/compute_engine_docker.ts
Comment thread src/components/c2d/compute_engine_docker.ts Outdated
Comment thread src/components/c2d/compute_engine_docker.ts
Comment thread src/components/c2d/compute_engine_docker.ts
Comment on lines +187 to +215
// Atomically takes the lock for serviceId: inserts a fresh row, or steals one whose
// acquiredAt is older than staleMs (crashed holder). The single upsert statement is
// the atomicity guarantee — two processes racing it can never both see success.
acquireServiceLock(
serviceId: string,
holder: string,
staleMs: number
): Promise<boolean> {
const now = Date.now()
const upsertSQL = `
INSERT INTO service_locks (serviceId, holder, acquiredAt) VALUES (?, ?, ?)
ON CONFLICT(serviceId) DO UPDATE
SET holder = excluded.holder, acquiredAt = excluded.acquiredAt
WHERE service_locks.acquiredAt <= ?;
`
return new Promise<boolean>((resolve, reject) => {
this.db.run(
upsertSQL,
[serviceId, holder, now, now - staleMs],
function (this: RunResult, err: Error | null) {
if (err) {
DATABASE_LOGGER.error(`Could not acquire service lock: ${err.message}`)
reject(err)
} else {
resolve(this.changes === 1)
}
}
)
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Add fencing before allowing stale lease takeover.

After process B steals a stale row, process A can resume and continue modifying the same Docker resources and job because acquisition returns only a boolean. Holder-scoped release does not stop A’s ongoing operation. Use a fencing generation verified before destructive/state-changing steps, or avoid takeover until the previous process is proven dead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/database/sqliteCompute.ts` around lines 187 - 215, Update
acquireServiceLock and its callers to return and propagate a fencing
generation/token rather than only a boolean. Require the current holder to
validate that token immediately before destructive or state-changing Docker/job
operations, and abort when validation fails after a stale takeover; ensure
release remains holder-scoped and cannot bypass fencing.

Comment thread src/components/database/sqliteCompute.ts
Comment thread src/test/integration/services.test.ts
Comment thread src/test/unit/service/serviceRestartRace.test.ts
Comment thread src/test/unit/service/serviceRestartRace.test.ts Outdated
@alexcos20

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Caution

Failed to replace (edit) comment. This is likely due to insufficient permissions or the comment being deleted.

Error details
putComment timed out

@coderabbitai coderabbitai 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.

Actionable comments posted: 5

🧹 Nitpick comments (1)
src/test/unit/service/serviceRestartRace.test.ts (1)

144-168: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Assert DB lease release on restart failure.

This verifies only serviceOpsInFlight cleanup; the DB lease assertion covers success elsewhere. Also assert releaseServiceLock(SERVICE_ID, 'test-holder') after the failed background operation settles.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/test/unit/service/serviceRestartRace.test.ts` around lines 144 - 168, Add
an assertion to the restartService failure-path test after the background
operation settles, verifying releaseServiceLock was called with SERVICE_ID and
'test-holder'. Keep the existing serviceOpsInFlight cleanup and persisted Error
assertions unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/c2d/compute_engine_docker.ts`:
- Around line 4058-4072: Move the CPU allocation release currently performed
before Docker cleanup into the successful teardown branch of stopService,
alongside clearing containerId and networkId. Keep the allocation retained when
cleanupError is present, so cores remain reserved while the old container may
still be running.
- Around line 3862-3878: Update the health-failure transition around
isServiceLocked and updateServiceJob so it is serialized with lifecycle
operations: acquire the service’s lifecycle lease before persisting Error, or
use an atomic conditional update that only changes the record if it remains in
the expected Running state. Preserve the fail-closed behavior when lease
acquisition or validation fails, and prevent a concurrent Restarting transition
from being overwritten.

In `@src/components/core/service/extendService.ts`:
- Around line 204-247: The service extension flow around claimLock and
updateServiceJob must persist an idempotent extension intent before claiming
payment, then recover or finalize that intent if persistence or later processing
fails. Ensure retries detect the existing intent and complete the expiresAt,
duration, and extendPayments update without charging again; only claim payment
after the intent is durable.

In `@src/components/core/service/utils.ts`:
- Around line 128-133: Update reserveHostPort to persist reservations in SQLite
rather than only adding to the process-local allocatedPorts set. Store a unique
reservation keyed by both port and service, and ensure concurrent processes
cannot select the same port while preserving idempotent behavior for repeated
reservations and restart flows.

In `@src/test/unit/service/serviceJobsDatabase.test.ts`:
- Around line 179-184: Update the refreshServiceLocks test so the lock becomes
older than the five-second staleness threshold before calling
refreshServiceLocks, using the test’s existing clock or time-control utilities.
Then keep the proc-B acquisition assertion to verify refreshServiceLocks
re-stamps proc-A’s row and prevents takeover.

---

Nitpick comments:
In `@src/test/unit/service/serviceRestartRace.test.ts`:
- Around line 144-168: Add an assertion to the restartService failure-path test
after the background operation settles, verifying releaseServiceLock was called
with SERVICE_ID and 'test-holder'. Keep the existing serviceOpsInFlight cleanup
and persisted Error assertions unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8bc0a991-ec99-4eb5-bc3d-a3ae49a11c8f

📥 Commits

Reviewing files that changed from the base of the PR and between 7bcf7f0 and ff48b73.

📒 Files selected for processing (18)
  • docs/services.md
  • src/@types/C2D/ServiceOnDemand.ts
  • src/components/c2d/compute_engine_base.ts
  • src/components/c2d/compute_engine_docker.ts
  • src/components/core/service/extendService.ts
  • src/components/core/service/getStreamableLogs.ts
  • src/components/core/service/restartService.ts
  • src/components/core/service/startService.ts
  • src/components/core/service/stopService.ts
  • src/components/core/service/utils.ts
  • src/components/database/C2DDatabase.ts
  • src/components/database/sqliteCompute.ts
  • src/test/integration/services.test.ts
  • src/test/unit/compute.test.ts
  • src/test/unit/service/serviceHandlers.test.ts
  • src/test/unit/service/serviceJobsDatabase.test.ts
  • src/test/unit/service/serviceNetworkCleanup.test.ts
  • src/test/unit/service/serviceRestartRace.test.ts

Comment thread src/components/c2d/compute_engine_docker.ts Outdated
Comment thread src/components/c2d/compute_engine_docker.ts
Comment thread src/components/core/service/extendService.ts
Comment on lines +128 to +133
// Marks an already-assigned port as reserved (idempotent). Used by restart, which
// re-binds the ports recorded on the job: after a stop (or an Error path) released
// them, they must go back into the set before the container binds them again.
export function reserveHostPort(port: number): void {
allocatedPorts.add(port)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Persist host-port reservations across processes.

The new lifecycle model supports multiple processes sharing one Docker daemon, but allocatedPorts remains process-local. Different services can concurrently select the same port; one paid service then fails to bind and cannot restart successfully on its recorded endpoint. Use a SQLite-backed unique reservation keyed by port and service.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/core/service/utils.ts` around lines 128 - 133, Update
reserveHostPort to persist reservations in SQLite rather than only adding to the
process-local allocatedPorts set. Store a unique reservation keyed by both port
and service, and ensure concurrent processes cannot select the same port while
preserving idempotent behavior for repeated reservations and restart flows.

Comment thread src/test/unit/service/serviceJobsDatabase.test.ts Outdated
@alexcos20

Copy link
Copy Markdown
Member Author

Fixed (5 of 6) in beb981d

  1. releaseCpus before teardown — valid: cores went back to the pinning pool before
    the container was confirmed gone, so a failed teardown left the old container running
    on cores another job could now be pinned to. Moved into the successful teardown branch
    of doStopService, alongside the id-clearing; on cleanupError the allocation is
    retained.

  2. markServiceFailed check-then-write race — valid: between the advisory
    isServiceLocked read and updateServiceJob, a restart could acquire the lease and
    persist Restarting, which the whole-body Error write would clobber. Now it
    acquires the actual lifecycle lease (tryAcquireServiceLifecycleLock), re-reads
    the job fresh under it, applies the same Running/containerId guards, writes, and
    releases in a finally. Fail-closed is preserved: any failure to acquire skips the
    tick and the next tick re-detects a genuinely dead container.

  3. Extend payment durability — valid: a crash between claimLock and the final write
    took the consumer's money with no record, and a retry charged again. Now the extension
    intent (an extendPayments entry with lockTx, empty claimTx/cancelTx) is
    persisted before claiming; success finalizes it (claimTx + expiresAt),
    claim-failure closes it with cancelTx. A later extend that finds an unresolved
    intent first tries to auto-refund the stale lock (using the intent's chain/token)
    and proceeds; if the refund fails (lock likely already claimed) it returns 409
    without attempting any new charge
    . Partially skipped: automatically completing a
    claimed-but-unfinalized extension — that requires on-chain proof the claim landed; the
    durable intent makes the case auditable and operator-recoverable instead of silent.

  4. Refresh-test staleness — valid: the lock row was milliseconds old, so the "not
    stolen" assertion held even if refreshServiceLocks were a no-op. The test now ages
    the original stamp 150 ms, refreshes, and has proc-B probe with a 100 ms staleness
    window — a refusal is only explainable by the re-stamp.

  5. Nitpick — added the releaseServiceLock(SERVICE_ID, 'test-holder') assertion to
    the restart failure-path test.

Skipped (1)

  • SQLite-backed host-port reservations — skipped: the end state is already fail-safe
    without it. Each process's allocator combines the in-memory set with a live OS bind
    probe, and if two processes still pick the same port in the millisecond window during
    simultaneous allocations (only possible in the already-degenerate shared-daemon
    two-process topology), Docker rejects the second bind at container.start() — a clean,
    retryable failed start, no corruption. A DB-backed allocator needs a schema, TTL/orphan
    cleanup, and a migration of the boot-seeding path; that's disproportionate here and
    noted as follow-up hardening alongside lease fencing.

@alexcos20

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

♻️ Duplicate comments (3)
src/components/c2d/compute_engine_docker.ts (1)

1870-1904: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Keep the Expired transition under the lifecycle lease.

stopService() releases the lease before this fresh read and write. A concurrent extension can update expiresAt in that gap, then be overwritten by Expired and lose its ports. Move the expiry recheck, terminal update, and port release into the locked onlyIfExpired operation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/c2d/compute_engine_docker.ts` around lines 1870 - 1904, The
expiry recheck and Expired transition in the service sweep currently occur after
stopService releases the lifecycle lease, allowing a concurrent extension to be
overwritten. Move the fresh expiresAt validation, terminal status update, and
releaseHostPort calls into the locked onlyIfExpired operation, ensuring the
operation revalidates expiration and performs the update and port release
atomically under the lifecycle lease.
src/components/core/service/utils.ts (1)

128-133: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Persist host-port reservations across processes.

This process-local Set allows separate node processes to reserve the same port for different services. Docker then rejects one paid service’s bind. Use a SQLite reservation with a unique port constraint.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/core/service/utils.ts` around lines 128 - 133, Update
reserveHostPort to persist reservations in SQLite rather than only adding to the
process-local allocatedPorts Set. Use a reservations table with a unique
constraint on the port and insert the requested port atomically, while
preserving idempotent behavior when the port is already reserved; ensure all
host-port allocation checks use the same database-backed reservation.
src/test/integration/services.test.ts (1)

816-829: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert that an InternalLoop tick occurs during PullImage.

The test can pass without exercising orphan recovery if no loop tick runs during the delay. Spy on and assert the pending-start query or equivalent loop activity before issuing concurrent commands.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/test/integration/services.test.ts` around lines 816 - 829, Update the
restart integration test’s PullImage wait around getServiceJob to also spy on
the pending-start query or equivalent InternalLoop activity, and assert that at
least one loop tick occurs while the service remains in PullImage before issuing
concurrent commands. Preserve the existing sawPullImage timeout assertion and
ensure the new assertion fails if no loop activity is observed during that
phase.
🧹 Nitpick comments (1)
src/test/unit/service/serviceJobsDatabase.test.ts (1)

328-365: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Cover expired Stopping jobs.

getExpiredServiceJobs() now includes Stopping specifically for crash-mid-stop recovery, but this test only exercises Running, Error, and Stopped. Add expired and future Stopping fixtures.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/test/unit/service/serviceJobsDatabase.test.ts` around lines 328 - 365,
Extend the getExpiredServiceJobs test with expired and future fixtures using
ServiceStatusNumber.Stopping, persist both through db.newServiceJob, and assert
the expired job is returned while the future job is excluded. Keep the existing
status coverage and expectations unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/c2d/compute_engine_docker.ts`:
- Around line 3902-3909: Update the service lock acquisition catch block around
the lease logic to fail closed instead of setting acquired = true. Roll back any
local reservation made before acquisition, then reject or defer the lifecycle
operation so Docker and database mutations cannot proceed without the SQLite
lease; preserve the warning log and ensure the failure is propagated to the
caller.

In `@src/components/core/service/extendService.ts`:
- Around line 241-254: Update the intent persistence flow in the extension
payment logic around freshJob.extendPayments and updateServiceJob so that, when
updateServiceJob throws, it calls cancelExpiredLock for the confirmed lockTx.
Confirm the cancellation succeeded; if cancellation fails or cannot be
confirmed, return HTTP 409, while preserving the existing error handling for
successful compensation.

---

Duplicate comments:
In `@src/components/c2d/compute_engine_docker.ts`:
- Around line 1870-1904: The expiry recheck and Expired transition in the
service sweep currently occur after stopService releases the lifecycle lease,
allowing a concurrent extension to be overwritten. Move the fresh expiresAt
validation, terminal status update, and releaseHostPort calls into the locked
onlyIfExpired operation, ensuring the operation revalidates expiration and
performs the update and port release atomically under the lifecycle lease.

In `@src/components/core/service/utils.ts`:
- Around line 128-133: Update reserveHostPort to persist reservations in SQLite
rather than only adding to the process-local allocatedPorts Set. Use a
reservations table with a unique constraint on the port and insert the requested
port atomically, while preserving idempotent behavior when the port is already
reserved; ensure all host-port allocation checks use the same database-backed
reservation.

In `@src/test/integration/services.test.ts`:
- Around line 816-829: Update the restart integration test’s PullImage wait
around getServiceJob to also spy on the pending-start query or equivalent
InternalLoop activity, and assert that at least one loop tick occurs while the
service remains in PullImage before issuing concurrent commands. Preserve the
existing sawPullImage timeout assertion and ensure the new assertion fails if no
loop activity is observed during that phase.

---

Nitpick comments:
In `@src/test/unit/service/serviceJobsDatabase.test.ts`:
- Around line 328-365: Extend the getExpiredServiceJobs test with expired and
future fixtures using ServiceStatusNumber.Stopping, persist both through
db.newServiceJob, and assert the expired job is returned while the future job is
excluded. Keep the existing status coverage and expectations unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f4c8de2f-b072-47be-8325-ff9b0a2702d4

📥 Commits

Reviewing files that changed from the base of the PR and between 7bcf7f0 and beb981d.

📒 Files selected for processing (18)
  • docs/services.md
  • src/@types/C2D/ServiceOnDemand.ts
  • src/components/c2d/compute_engine_base.ts
  • src/components/c2d/compute_engine_docker.ts
  • src/components/core/service/extendService.ts
  • src/components/core/service/getStreamableLogs.ts
  • src/components/core/service/restartService.ts
  • src/components/core/service/startService.ts
  • src/components/core/service/stopService.ts
  • src/components/core/service/utils.ts
  • src/components/database/C2DDatabase.ts
  • src/components/database/sqliteCompute.ts
  • src/test/integration/services.test.ts
  • src/test/unit/compute.test.ts
  • src/test/unit/service/serviceHandlers.test.ts
  • src/test/unit/service/serviceJobsDatabase.test.ts
  • src/test/unit/service/serviceNetworkCleanup.test.ts
  • src/test/unit/service/serviceRestartRace.test.ts

Comment on lines +3902 to +3909
} catch (e: any) {
// DB trouble must not brick every lifecycle op on a single-process node — fall
// back to in-process-only locking (the pre-lease behavior).
CORE_LOGGER.warn(
`service lock DB acquire failed for ${serviceId} (falling back to in-process lock): ${e.message}`
)
acquired = true
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Fail closed when SQLite lease acquisition fails.

Setting acquired = true allows Docker and database mutations while another process may own the lease, recreating the lifecycle race this PR fixes. Roll back the local reservation and reject or defer the operation.

Proposed fix
     } catch (e: any) {
-      CORE_LOGGER.warn(
-        `service lock DB acquire failed for ${serviceId} (falling back to in-process lock): ${e.message}`
-      )
-      acquired = true
+      this.serviceOpsInFlight.delete(serviceId)
+      CORE_LOGGER.error(`service lock DB acquire failed for ${serviceId}: ${e.message}`)
+      return false
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/c2d/compute_engine_docker.ts` around lines 3902 - 3909, Update
the service lock acquisition catch block around the lease logic to fail closed
instead of setting acquired = true. Roll back any local reservation made before
acquisition, then reject or defer the lifecycle operation so Docker and database
mutations cannot proceed without the SQLite lease; preserve the warning log and
ensure the failure is propagated to the caller.

Comment on lines +241 to +254
// Persist the extension INTENT (lockTx recorded, claim pending) BEFORE
// claiming: a crash between claim and the final write is then auditable — the
// consumer's money can never be taken without a durable record of why — and a
// retry finds the intent (unresolved branch above) instead of charging twice.
const intent = {
chainId: task.payment.chainId,
token: task.payment.token,
lockTx,
claimTx: '',
cancelTx: '',
cost: costExtend
}
freshJob.extendPayments = [...(freshJob.extendPayments ?? []), intent]
await engine.db.updateServiceJob(freshJob)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Cancel the escrow lock when intent persistence fails.

If updateServiceJob() throws here, the outer catch returns without cancelling the confirmed lock or recording its intent. A retry can create another lock for the same extension. Compensate this failed write with cancelExpiredLock; return 409 if cancellation cannot be confirmed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/core/service/extendService.ts` around lines 241 - 254, Update
the intent persistence flow in the extension payment logic around
freshJob.extendPayments and updateServiceJob so that, when updateServiceJob
throws, it calls cancelExpiredLock for the confirmed lockTx. Confirm the
cancellation succeeded; if cancellation fails or cannot be confirmed, return
HTTP 409, while preserving the existing error handling for successful
compensation.

@alexcos20

Copy link
Copy Markdown
Member Author

New command: SERVICE_LIST (GetServicesHandler) in d6153a0

SERVICE_LIST (GET /api/services/serviceList) is the node-wide service listing, shaped
like GetJobsHandler (GetServicesCommand / GetServicesHandler):

  • Default (no filters): only the services currently holding a resource
    reservation
    — exactly what the engines count against the shared pools via
    getRunningServiceJobs: Running/Restarting/Stopping, the mid-start pipeline states,
    paid Error (container died, restartable), and explicitly Stopped (reservation kept
    until expiresAt). Expired and never-paid jobs hold nothing and are not listed.
  • status=<n>: filter to ONE specific ServiceStatusNumber (any status, incl.
    Expired); takes precedence over includeAllStatuses.
  • includeAllStatuses=true: every status, regardless of reservations.
  • fromTimestamp: only services created at/after that moment — ISO date string or a
    Unix timestamp (seconds or milliseconds).

Authenticated like every other service command (consumerAddress + signature or auth
token) but — deliberately — not owner-scoped: any consumer identity can see every
owner's services. The output is therefore listing-sanitized (toListedServiceJob):
on top of the always-stripped userData (the env blob), dockerCmd,
dockerEntrypoint, dockerfile and additionalDockerFiles are removed — identity,
status, resources, endpoints and payment metadata are kept. The per-owner full view
remains SERVICE_GET_STATUS.

Wiring follows the standard command pattern: PROTOCOL_COMMANDS.SERVICE_LIST +
supported-commands entry, GetServicesCommand type, GetServicesHandler
(core/service/getServices.ts), registry registration, REST route with the filter query
params. Documented in docs/services.md and docs/API.md, and added to the Postman
collection ("List Services" under Service on Demand). Covered by six unit tests
(missing-address 400, invalid status / fromTimestamp 400, default listing cross-owner +
sanitization, status filter incl. Expired, includeAllStatuses + fromTimestamp in both ISO
and Unix forms, empty list) and integration test (e2), which exercises default listing,
the status filter, a future fromTimestamp, sanitization, and unauthenticated rejection
against a live node.

@alexcos20 alexcos20 changed the title fix race bug services updates Jul 17, 2026
@alexcos20
alexcos20 merged commit 78fbc3b into next-4 Jul 17, 2026
19 of 20 checks passed
@alexcos20
alexcos20 deleted the bug/raceloop_services branch July 17, 2026 05:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants