Skip to content

fix race bug#1422

Open
alexcos20 wants to merge 8 commits into
next-4from
bug/raceloop_services
Open

fix race bug#1422
alexcos20 wants to merge 8 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 a visible Restarting service status for asynchronous restarts.
    • Improved lifecycle exclusivity so only one operation per service runs at a time across processes (conflicting requests return “operation in progress” with retry guidance).
    • Restarts preserve paid reservations/endpoints until expiry, enabling reuse of the same host port.
  • Bug Fixes
    • Refined orphan recovery, Docker/network cleanup behavior, and “unexpected death” handling to avoid releasing reserved resources prematurely.
    • Added a best-effort escrow funds pre-check for service start, with fast “Insufficient escrow funds” errors when applicable.
  • Documentation
    • Updated service-on-demand lifecycle docs with clearer async/restart/stop and polling semantics.

@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 25c8524b-abf3-4bf5-b27c-ee150ab87ed8

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 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, 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/..., src/test/unit/service/serviceJobsDatabase.test.ts
Adds Restarting, SQLite-backed service locks, heartbeat and stale-lock handling, and updated active, pending, and expired reservation queries.
Locked lifecycle orchestration
src/components/c2d/compute_engine_docker.ts, docs/services.md
Coordinates start, stop, restart, and expiry operations under per-service locks while preserving ports until expiry and improving Docker cleanup diagnostics.
Shared service lookup and command entry points
src/components/core/service/*
Centralizes service-job and owning-engine resolution and separates missing-job responses from missing-engine errors.
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, expiry retries, stale snapshots, 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, dnsi0

🚥 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 vague to describe the main change; it doesn't indicate the service lifecycle race fixes or locking work. Use a specific title such as 'Fix service lifecycle race conditions in Docker engine'.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
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.
✨ Finishing Touches
🧪 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 on lines +786 to +823
it('(l3) SERVICE_RESTART survives InternalLoop ticks mid-restart (orphan-recovery race)', async function () {
this.timeout(DEFAULT_TEST_TIMEOUT * 4)
const engine: any = getDockerEngine()
const before = await getServiceJob(serviceId)
const oldContainerId = before.containerId

// Delay the image pull so the job sits in PullImage across several InternalLoop
// ticks (cronTime = 2 s) — the exact window in which the loop's orphan-recovery
// used to tear down the network the restart had just created, failing the restart
// with "network ocean-svc-<id> not found" (the live-node bug).
const originalPull = engine.pullImageRef
engine.pullImageRef = async (...args: any[]) => {
await sleep(5000)
return originalPull.apply(engine, args)
}
try {
const {
consumerAddress: addr,
nonce,
signature
} = await signFor(consumerAccount, PROTOCOL_COMMANDS.SERVICE_RESTART)
const task: ServiceRestartCommand = {
command: PROTOCOL_COMMANDS.SERVICE_RESTART,
consumerAddress: addr,
nonce,
signature,
serviceId
}
const restartPromise = new ServiceRestartHandler(oceanNode).handle(task)

// wait until the restart is actually mid-pull (deterministic, not sleep-based)
const deadline = Date.now() + 10_000
while (Date.now() < deadline) {
const j = await getServiceJob(serviceId)
if (j?.status === ServiceStatusNumber.PullImage) break
await sleep(250)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the orphan-recovery synchronization observable.

Lines 817-823 silently continue if PullImage is never reached, and the test never verifies that an InternalLoop tick occurred during that state. It can therefore pass without exercising the advertised race. Assert both conditions before issuing the 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 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.

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 on lines 3862 to 3878
// A fresh DB lease means ANOTHER process is mid-operation on this service (its
// teardown makes the container look dead); our in-memory serviceOpsInFlight can't
// see that. Skip — if the container is genuinely dead, the next tick catches it.
// Fail CLOSED: when the lease state can't be read, assume locked and skip this
// tick rather than risk flipping a mid-restart service to Error.
const lockedElsewhere = await this.db
.isServiceLocked(job.serviceId, SERVICE_LOCK_STALE_MS)
.catch(() => true)
if (lockedElsewhere) {
CORE_LOGGER.debug(
`markServiceFailed ${job.serviceId}: skipped — a lifecycle lease is held elsewhere`
)
return
}
fresh.status = ServiceStatusNumber.Error
fresh.statusText = `service container exited unexpectedly: ${reason}`
await this.db.updateServiceJob(fresh)

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

Serialize the health-failure transition with lifecycle operations.

isServiceLocked() is only an observation. Another process can acquire the lease and persist Restarting after this check but before updateServiceJob(), allowing this stale Running snapshot to overwrite it with Error. Acquire the lifecycle lease or use an atomic conditional update.

🤖 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 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.

Comment on lines 4058 to +4072
if (cleanupError) {
await this.logServiceDockerState(
`stop ${serviceId}: FAILED (${cleanupError.message}) — docker state at failure`,
serviceId
)
job.status = ServiceStatusNumber.Error
job.statusText = `stop failed: ${cleanupError.message}`
} else {
job.status = ServiceStatusNumber.Stopped
job.statusText = ServiceStatusText[ServiceStatusNumber.Stopped]
// The docker objects are confirmed gone — drop the stale ids so nothing later
// mistakes them for live resources (a restart re-creates and re-persists both).
job.containerId = ''
job.networkId = ''
CORE_LOGGER.debug(`stopService ${serviceId}: stopped, container + network removed`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Retain CPU pinning when teardown fails.

Line 4033 releases the service’s CPU allocation before Docker cleanup is confirmed. If stopping fails and the old container remains alive, another workload can be assigned the same physical cores. Release the allocation only in the successful teardown branch.

🤖 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 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.

Comment on lines +204 to +247
let claimTx: string | null
try {
claimTx = await engine.escrow.claimLock(
task.payment.chainId,
task.serviceId,
task.payment.token,
task.consumerAddress,
costExtend,
`service-extend:${task.serviceId}`
)
} catch (e: any) {
claimTx = null
CORE_LOGGER.error(`Service extend claimLock failed: ${e.message}`)
}
if (!claimTx) {
await engine.escrow
.cancelExpiredLock(
task.payment.chainId,
task.serviceId,
task.payment.token,
task.consumerAddress
)
.catch((e) => CORE_LOGGER.error(`cancelExpiredLock failed: ${e.message}`))
return {
stream: null,
status: { httpStatus: 402, error: 'Escrow claim failed — lock cancelled' }
}
}

CORE_LOGGER.logMessage(
`Service ${task.serviceId} extended by ${task.additionalDuration}s, new expiresAt: ${job.expiresAt}`,
true
)
return {
stream: Readable.from(JSON.stringify([toPublicServiceJob(job)])),
status: { httpStatus: 200 }
// Payment successful — push expiresAt forward and record extension payment
freshJob.expiresAt += task.additionalDuration * 1000
freshJob.duration += task.additionalDuration
freshJob.extendPayments = [
...(freshJob.extendPayments ?? []),
{
chainId: task.payment.chainId,
token: task.payment.token,
lockTx,
claimTx,
cancelTx: '',
cost: costExtend
}
]
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 | 🔴 Critical | 🏗️ Heavy lift

Make the extension durable before claiming payment.

If claimLock() succeeds but updateServiceJob() fails, the consumer is charged while expiresAt remains unchanged; the outer catch then encourages a retry. Persist an idempotent extension intent before claiming and recover/finalize it after failures.

🤖 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 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.

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 on lines +179 to +184
it('refreshServiceLocks re-stamps a holder’s rows so they stay unstealable', async () => {
const serviceId = `lock-svc-${Date.now()}-d`
expect(await db.acquireServiceLock(serviceId, 'proc-A', STALE_MS)).to.equal(true)
await db.refreshServiceLocks('proc-A')
// B considers anything older than 5s stale — A's row was just re-stamped
expect(await db.acquireServiceLock(serviceId, 'proc-B', 5_000)).to.equal(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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the heartbeat test fail when refreshServiceLocks is a no-op.

The lease is already fresh under the five-second threshold before refresh. Advance or fake time past the threshold before refreshing, then verify holder B cannot acquire.

🤖 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 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.

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

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