mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
fix: release consumed runtime RPC queue entries (#21131)
Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
# Completed RPC queue records remain reachable
|
||||
|
||||
Status: reproduced before and after the product fix on Node 26 and installed Electron 43.7.0 in Node mode.
|
||||
|
||||
`RuntimeRpcCallQueuePool` advances each lane's head without clearing the consumed array element. While any call keeps that selector active, completed records keep their `run`, `resolve`, `reject`, and signal fields reachable until that same lane compacts. Compaction requires more than 32 consumed entries and at least half the array consumed. Selector deletion also releases the arrays when every active and queued call finishes.
|
||||
|
||||
The fix assigns `undefined` to the consumed slot in `takeForeground` and `takeBackground` and permits undefined in the two array element types. An active call remains owned by its promise cleanup closure. Queued entries, both lane heads, counts, admission thresholds, batching, foreground preference, and cancellation behavior stay intact.
|
||||
|
||||
## Production reachability
|
||||
|
||||
- Desktop `src/main/ipc/runtime-environment-call-queue.ts` owns a module singleton. `runtime-environment-transport-routing.ts::callRuntimeEnvironment` passes a closure capturing request params, environment, and optional orchestration envelope. Its normal timeout is 15 seconds; `status.get` bypasses this queue.
|
||||
- Paired web `src/renderer/src/web/preload-api/web-runtime-session.ts` owns another module singleton. `web-runtime-calls.ts` and `web-filesystem-api.ts::captureWebFileMutationSession` pass closures capturing params, environment, and sometimes an explicit client. Web request timeout defaults to 30 seconds after connection readiness.
|
||||
- Current production callers pass/default `retainedBytes` to zero. The nonzero byte values in the proof exercise the queue's accounting; they are not evidence that deployed callers account their object graphs here.
|
||||
- These are remote/paired execution paths. The finding does not explain local-only #19831 from code reachability alone.
|
||||
|
||||
Finite individual call duration does not guarantee that an old lane's consumed records disappear: overlapping calls in the other lane can keep the selector active. The extended proof completes 70 successive background calls while eight completed foreground payloads stay reachable before the fix; each background call finishes, and releasing the final call makes the selector idle and permits collection.
|
||||
|
||||
This is retention beyond useful lifetime, bounded in record count by existing compaction/admission behavior. It is not proof of unlimited growth for one selector or of any reported incident's magnitude. The isolated payloads are bounded dummy arrays rather than a real network workload.
|
||||
|
||||
## Proofs
|
||||
|
||||
`reproduce.cjs` bundles the actual queue module with its actual imports. `queue-source.cjs` reconstructs the baseline in memory by reversing `fix.patch`; both baseline and current source hashes must match `source-versions.json`. Eight completed calls capture eight 1 MiB typed-array payloads, with one other call holding the selector active. Weak references remain live before and all clear after the fix. Foreground and background lanes, eventual compaction, and eventual idle cleanup are covered. Queue byte credit and queued-call count already equal zero during stale retention. At most eight payload MiB are intentionally live in each fixture; the process has a 128 MiB old-space limit and a ten-second deadline.
|
||||
|
||||
`extended-controls.cjs` checks active payloads are retained until their call settles, cancellation releases queued payloads without waiting for unrelated active calls, synchronous failure releases only after compaction/idle before the fix, rolling cross-lane traffic, and a 140-call mixed burst with six cancellations. Both variants execute the same 134 remaining calls in FIFO lane order with foreground priority, then delete the idle selector.
|
||||
|
||||
`resolver-controls.cjs` isolates the runtime's settled-promise resolver behavior without using the queue. Keeping native resolve functions can also keep settled results reachable in some runtime versions; this must be reported separately from input closures.
|
||||
|
||||
Node and Electron results are stored separately. Node 26.6.0/V8 14.6 collects fresh response payloads even with the stale queue records. Installed Electron 43.7.0/Node 24.21.0/V8 15.0 retains all eight fresh response payloads before and releases them after the fix. The standalone resolver control reproduces the same difference: saving native resolve functions retains eight of eight payloads in Electron and zero in Node; releasing those functions permits collection in both. This control isolates runtime promise behavior without claiming all Electron versions or browser renderer modes behave identically.
|
||||
|
||||
The original Node-only negative-control expectation for response retention failed under Electron. The baseline now records that result rather than assuming every V8 version releases settled results identically. The fixed variant must release responses in both environments. This proof does not measure the exact Electron 43.4.1 historical binary.
|
||||
|
||||
Run from the worktree:
|
||||
|
||||
```sh
|
||||
ORCA_BACKGROUND_LAUNCH=1 node --expose-gc --max-old-space-size=128 docs/audits/runtime-rpc-consumed-queue/reproduce.cjs
|
||||
ORCA_BACKGROUND_LAUNCH=1 node --expose-gc --max-old-space-size=128 docs/audits/runtime-rpc-consumed-queue/extended-controls.cjs
|
||||
ORCA_BACKGROUND_LAUNCH=1 node --expose-gc --max-old-space-size=128 docs/audits/runtime-rpc-consumed-queue/resolver-controls.cjs
|
||||
ORCA_BACKGROUND_LAUNCH=1 node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/shared/runtime-rpc-call-queue.test.ts src/shared/runtime-rpc-call-queue-retention.test.ts
|
||||
```
|
||||
|
||||
The installed Electron binary can run the same scripts with `ELECTRON_RUN_AS_NODE=1`, `ORCA_BACKGROUND_LAUNCH=1`, and the same Node flags. No Electron app or window is created. No network, microphone, or affected-host data is used. No heap-snapshot tools are exposed in this session; the proof measures WeakRef reachability and bounded process counters instead of reading raw heap snapshots.
|
||||
|
||||
The existing eight queue tests and six added retention/lifecycle tests pass with the fix. To reproduce the three retention failures against the reconstructed baseline, use `ORCA_BACKGROUND_LAUNCH=1 node --expose-gc node_modules/vitest/vitest.mjs run --config docs/audits/runtime-rpc-consumed-queue/baseline.config.mjs src/shared/runtime-rpc-call-queue.test.ts src/shared/runtime-rpc-call-queue-retention.test.ts`; the expected outcome is 11 passing tests and three failures, with exit code 1. Node and Web project typechecks and the changed-code quality gate passed during promotion. Explicit basic/type-aware lint also covers these otherwise ignored audit scripts.
|
||||
|
||||
## Source identity
|
||||
|
||||
The queue source before the fix, fetched `origin/main`, and `v1.4.198` all had SHA-256 `45921658a10ffeefe0247673227aab643d1b5251d24aa460244fadd5480fc349` at review. `source-versions.json` records exact baseline/current hashes and compared commit IDs. Availability in that release supports reachability analysis; it does not attribute an incident.
|
||||
@@ -0,0 +1,30 @@
|
||||
import path from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
import { defineConfig, mergeConfig } from 'vitest/config'
|
||||
import rootConfig from '../../../config/vitest.config.ts'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const proof = require('./queue-source.cjs')
|
||||
const sourcePath = path.resolve(import.meta.dirname, '../../..', proof.versions.sourcePath)
|
||||
export default mergeConfig(
|
||||
rootConfig,
|
||||
defineConfig({
|
||||
plugins: [
|
||||
{
|
||||
name: 'runtime-rpc-queue-baseline',
|
||||
enforce: 'pre',
|
||||
load(id) {
|
||||
if (id === sourcePath) {
|
||||
return proof.baselineSource
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
test: {
|
||||
include: [
|
||||
'src/shared/runtime-rpc-call-queue.test.ts',
|
||||
'src/shared/runtime-rpc-call-queue-retention.test.ts'
|
||||
]
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"node": "v24.21.0",
|
||||
"electron": "43.7.0",
|
||||
"versions": {
|
||||
"sourcePath": "src/shared/runtime-rpc-call-queue.ts",
|
||||
"baselineSha256": "45921658a10ffeefe0247673227aab643d1b5251d24aa460244fadd5480fc349",
|
||||
"fixedSha256": "6afe6eb8acd9025d227960fafef77ae205a455c7f680b15ad0db5cdfee18c71d",
|
||||
"matchingBaselineRefs": {
|
||||
"origin/main": "77cd61df396f25ec91ee2d5ddcbd1f55aa94f818",
|
||||
"v1.4.198": "e0826956fcfc532f5a1e55b5e081f2e57e553c43"
|
||||
}
|
||||
},
|
||||
"proofSha256": "af64f8b55f070d4a863ff6b8f456d518d977cc06400bdbbbba60eb2c5a455aa2",
|
||||
"reports": [
|
||||
{
|
||||
"candidate": false,
|
||||
"case": "active-and-cancelled",
|
||||
"lane": "foreground",
|
||||
"activeRetainedUntilSettlement": true,
|
||||
"cancelledReleasedBeforeActiveFinishes": true
|
||||
},
|
||||
{
|
||||
"candidate": false,
|
||||
"case": "synchronous-failure",
|
||||
"lane": "foreground",
|
||||
"retainedWhileOtherCallActive": 1,
|
||||
"retainedAfterIdle": 0
|
||||
},
|
||||
{
|
||||
"candidate": false,
|
||||
"case": "active-and-cancelled",
|
||||
"lane": "background",
|
||||
"activeRetainedUntilSettlement": true,
|
||||
"cancelledReleasedBeforeActiveFinishes": true
|
||||
},
|
||||
{
|
||||
"candidate": false,
|
||||
"case": "synchronous-failure",
|
||||
"lane": "background",
|
||||
"retainedWhileOtherCallActive": 1,
|
||||
"retainedAfterIdle": 0
|
||||
},
|
||||
{
|
||||
"candidate": false,
|
||||
"case": "rolling-background-traffic",
|
||||
"completedForegroundPayloads": 8,
|
||||
"completedBackgroundCalls": 70,
|
||||
"foregroundPayloadsStillRetained": 8,
|
||||
"retainedAfterIdle": 0,
|
||||
"everyBackgroundCallCompletes": true
|
||||
},
|
||||
{
|
||||
"candidate": false,
|
||||
"case": "ordering-compaction-and-cancellation",
|
||||
"completed": 134,
|
||||
"cancelled": 6,
|
||||
"foregroundBeforeBackground": true,
|
||||
"fifoWithinEachLane": true,
|
||||
"finalQueueCount": 0
|
||||
},
|
||||
{
|
||||
"candidate": true,
|
||||
"case": "active-and-cancelled",
|
||||
"lane": "foreground",
|
||||
"activeRetainedUntilSettlement": true,
|
||||
"cancelledReleasedBeforeActiveFinishes": true
|
||||
},
|
||||
{
|
||||
"candidate": true,
|
||||
"case": "synchronous-failure",
|
||||
"lane": "foreground",
|
||||
"retainedWhileOtherCallActive": 0,
|
||||
"retainedAfterIdle": 0
|
||||
},
|
||||
{
|
||||
"candidate": true,
|
||||
"case": "active-and-cancelled",
|
||||
"lane": "background",
|
||||
"activeRetainedUntilSettlement": true,
|
||||
"cancelledReleasedBeforeActiveFinishes": true
|
||||
},
|
||||
{
|
||||
"candidate": true,
|
||||
"case": "synchronous-failure",
|
||||
"lane": "background",
|
||||
"retainedWhileOtherCallActive": 0,
|
||||
"retainedAfterIdle": 0
|
||||
},
|
||||
{
|
||||
"candidate": true,
|
||||
"case": "rolling-background-traffic",
|
||||
"completedForegroundPayloads": 8,
|
||||
"completedBackgroundCalls": 70,
|
||||
"foregroundPayloadsStillRetained": 0,
|
||||
"retainedAfterIdle": 0,
|
||||
"everyBackgroundCallCompletes": true
|
||||
},
|
||||
{
|
||||
"candidate": true,
|
||||
"case": "ordering-compaction-and-cancellation",
|
||||
"completed": 134,
|
||||
"cancelled": 6,
|
||||
"foregroundBeforeBackground": true,
|
||||
"fifoWithinEachLane": true,
|
||||
"finalQueueCount": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"node": "v24.21.0",
|
||||
"electron": "43.7.0",
|
||||
"v8": "15.0.245.31-electron.0",
|
||||
"proofSha256": "725087b61b9d79bc2daff4fe45f52951c531dcf9d23137a4939508957848350e",
|
||||
"payloads": 8,
|
||||
"bytesPerPayload": 1048576,
|
||||
"retainedWithResolveFunctions": 8,
|
||||
"retainedAfterResolveFunctionsReleased": 0
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
{
|
||||
"node": "v24.21.0",
|
||||
"electron": "43.7.0",
|
||||
"versions": {
|
||||
"sourcePath": "src/shared/runtime-rpc-call-queue.ts",
|
||||
"baselineSha256": "45921658a10ffeefe0247673227aab643d1b5251d24aa460244fadd5480fc349",
|
||||
"fixedSha256": "6afe6eb8acd9025d227960fafef77ae205a455c7f680b15ad0db5cdfee18c71d",
|
||||
"matchingBaselineRefs": {
|
||||
"origin/main": "77cd61df396f25ec91ee2d5ddcbd1f55aa94f818",
|
||||
"v1.4.198": "e0826956fcfc532f5a1e55b5e081f2e57e553c43"
|
||||
}
|
||||
},
|
||||
"proofSha256": "79e415e5f7de6516b4e4bd8fc8ab0c6d0ac139efe996b8c39ef4cf7b8659bacb",
|
||||
"reports": [
|
||||
{
|
||||
"candidate": false,
|
||||
"kind": "input",
|
||||
"lane": "foreground",
|
||||
"completed": 8,
|
||||
"releaseByIdle": false,
|
||||
"historyLength": 9,
|
||||
"head": 9,
|
||||
"retainedBeforeCompaction": 8,
|
||||
"retainedAfterCompaction": 0,
|
||||
"activeCreditBytesAfterCompleted": 0,
|
||||
"finalQueues": 0,
|
||||
"memoryDelta": {
|
||||
"external": 8378970,
|
||||
"heapUsed": -303508
|
||||
}
|
||||
},
|
||||
{
|
||||
"candidate": false,
|
||||
"kind": "input",
|
||||
"lane": "background",
|
||||
"completed": 8,
|
||||
"releaseByIdle": false,
|
||||
"historyLength": 8,
|
||||
"head": 8,
|
||||
"retainedBeforeCompaction": 8,
|
||||
"retainedAfterCompaction": 0,
|
||||
"activeCreditBytesAfterCompleted": 0,
|
||||
"finalQueues": 0,
|
||||
"memoryDelta": {
|
||||
"external": 8388608,
|
||||
"heapUsed": -2300
|
||||
}
|
||||
},
|
||||
{
|
||||
"candidate": false,
|
||||
"kind": "input",
|
||||
"lane": "foreground",
|
||||
"completed": 8,
|
||||
"releaseByIdle": true,
|
||||
"historyLength": 9,
|
||||
"head": 9,
|
||||
"retainedBeforeCompaction": 8,
|
||||
"retainedAfterCompaction": 0,
|
||||
"activeCreditBytesAfterCompleted": 0,
|
||||
"finalQueues": 0,
|
||||
"memoryDelta": {
|
||||
"external": 8388608,
|
||||
"heapUsed": -860
|
||||
}
|
||||
},
|
||||
{
|
||||
"candidate": false,
|
||||
"kind": "result",
|
||||
"lane": "foreground",
|
||||
"completed": 8,
|
||||
"releaseByIdle": false,
|
||||
"historyLength": 9,
|
||||
"head": 9,
|
||||
"retainedBeforeCompaction": 8,
|
||||
"retainedAfterCompaction": 0,
|
||||
"activeCreditBytesAfterCompleted": 0,
|
||||
"finalQueues": 0,
|
||||
"memoryDelta": {
|
||||
"external": 8388608,
|
||||
"heapUsed": 14952
|
||||
}
|
||||
},
|
||||
{
|
||||
"candidate": true,
|
||||
"kind": "input",
|
||||
"lane": "foreground",
|
||||
"completed": 8,
|
||||
"releaseByIdle": false,
|
||||
"historyLength": 9,
|
||||
"head": 9,
|
||||
"retainedBeforeCompaction": 0,
|
||||
"retainedAfterCompaction": 0,
|
||||
"activeCreditBytesAfterCompleted": 0,
|
||||
"finalQueues": 0,
|
||||
"memoryDelta": {
|
||||
"external": -9748,
|
||||
"heapUsed": -2608
|
||||
}
|
||||
},
|
||||
{
|
||||
"candidate": true,
|
||||
"kind": "input",
|
||||
"lane": "background",
|
||||
"completed": 8,
|
||||
"releaseByIdle": false,
|
||||
"historyLength": 8,
|
||||
"head": 8,
|
||||
"retainedBeforeCompaction": 0,
|
||||
"retainedAfterCompaction": 0,
|
||||
"activeCreditBytesAfterCompleted": 0,
|
||||
"finalQueues": 0,
|
||||
"memoryDelta": {
|
||||
"external": 0,
|
||||
"heapUsed": 4176
|
||||
}
|
||||
},
|
||||
{
|
||||
"candidate": true,
|
||||
"kind": "input",
|
||||
"lane": "foreground",
|
||||
"completed": 8,
|
||||
"releaseByIdle": true,
|
||||
"historyLength": 9,
|
||||
"head": 9,
|
||||
"retainedBeforeCompaction": 0,
|
||||
"retainedAfterCompaction": 0,
|
||||
"activeCreditBytesAfterCompleted": 0,
|
||||
"finalQueues": 0,
|
||||
"memoryDelta": {
|
||||
"external": 0,
|
||||
"heapUsed": 2496
|
||||
}
|
||||
},
|
||||
{
|
||||
"candidate": true,
|
||||
"kind": "result",
|
||||
"lane": "foreground",
|
||||
"completed": 8,
|
||||
"releaseByIdle": false,
|
||||
"historyLength": 9,
|
||||
"head": 9,
|
||||
"retainedBeforeCompaction": 0,
|
||||
"retainedAfterCompaction": 0,
|
||||
"activeCreditBytesAfterCompleted": 0,
|
||||
"finalQueues": 0,
|
||||
"memoryDelta": {
|
||||
"external": 0,
|
||||
"heapUsed": -3112
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { load, collect, sha, versions } = require('./queue-source.cjs')
|
||||
|
||||
const PAYLOAD_BYTES = 1024 * 1024
|
||||
function liveCall(queue, method = 'git.status') {
|
||||
const hold = Promise.withResolvers()
|
||||
return {
|
||||
release: () => hold.resolve(),
|
||||
settled: queue.enqueue('fixture', method, () => hold.promise)
|
||||
}
|
||||
}
|
||||
function payloadCall(queue, method, hold, signal, throwSynchronously = false) {
|
||||
const payload = new Uint8Array(PAYLOAD_BYTES)
|
||||
payload[0] = 19
|
||||
const ref = new WeakRef(payload)
|
||||
const settled = queue.enqueue(
|
||||
'fixture',
|
||||
method,
|
||||
() => {
|
||||
if (throwSynchronously) {
|
||||
throw new Error(`fixture failure ${payload[0]}`)
|
||||
}
|
||||
return hold.then(() => payload[0])
|
||||
},
|
||||
payload.byteLength,
|
||||
signal
|
||||
)
|
||||
return { ref, settled }
|
||||
}
|
||||
function liveCount(refs) {
|
||||
return refs.filter((ref) => ref.deref() !== undefined).length
|
||||
}
|
||||
|
||||
async function checkActiveAndCancelled(Queue, candidate, lane) {
|
||||
const queue = new Queue(1, 1)
|
||||
const method = lane === 'foreground' ? 'terminal.send' : 'git.status'
|
||||
const hold = Promise.withResolvers()
|
||||
const active = payloadCall(queue, method, hold.promise)
|
||||
const controller = new AbortController()
|
||||
const queued = payloadCall(queue, method, Promise.resolve(), controller.signal)
|
||||
const rejected = assert.rejects(queued.settled, { name: 'AbortError' })
|
||||
await collect()
|
||||
assert.equal(liveCount([active.ref, queued.ref]), 2)
|
||||
assert.equal(queue.retainedCallBytes, 2 * PAYLOAD_BYTES)
|
||||
controller.abort()
|
||||
await rejected
|
||||
await collect()
|
||||
assert.equal(liveCount([active.ref]), 1)
|
||||
assert.equal(liveCount([queued.ref]), 0)
|
||||
assert.equal(queue.retainedCallBytes, PAYLOAD_BYTES)
|
||||
assert.equal(queue.queuedCallCount, 0)
|
||||
hold.resolve()
|
||||
assert.equal(await active.settled, 19)
|
||||
await collect()
|
||||
assert.equal(liveCount([active.ref, queued.ref]), 0)
|
||||
assert.equal(queue.retainedCallBytes, 0)
|
||||
assert.equal(queue.queues.size, 0)
|
||||
return {
|
||||
candidate,
|
||||
case: 'active-and-cancelled',
|
||||
lane,
|
||||
activeRetainedUntilSettlement: true,
|
||||
cancelledReleasedBeforeActiveFinishes: true
|
||||
}
|
||||
}
|
||||
|
||||
async function checkFailure(Queue, candidate, lane) {
|
||||
const queue = new Queue(3, 2)
|
||||
const hold = liveCall(queue, 'terminal.send')
|
||||
const method = lane === 'foreground' ? 'terminal.send' : 'git.status'
|
||||
let failed = payloadCall(queue, method, Promise.resolve(), undefined, true)
|
||||
const ref = failed.ref
|
||||
await assert.rejects(failed.settled, { message: 'fixture failure 19' })
|
||||
failed = null
|
||||
await collect()
|
||||
const retained = liveCount([ref])
|
||||
assert.equal(retained, candidate ? 0 : 1)
|
||||
assert.equal(queue.retainedCallBytes, 0)
|
||||
hold.release()
|
||||
await hold.settled
|
||||
await collect()
|
||||
assert.equal(liveCount([ref]), 0)
|
||||
assert.equal(queue.queues.size, 0)
|
||||
return {
|
||||
candidate,
|
||||
case: 'synchronous-failure',
|
||||
lane,
|
||||
retainedWhileOtherCallActive: retained,
|
||||
retainedAfterIdle: 0
|
||||
}
|
||||
}
|
||||
|
||||
async function checkCrossLaneTraffic(Queue, candidate) {
|
||||
const queue = new Queue(3, 2)
|
||||
let current = liveCall(queue)
|
||||
const refs = []
|
||||
for (let index = 0; index < 8; index++) {
|
||||
const item = payloadCall(queue, 'terminal.send', Promise.resolve())
|
||||
refs.push(item.ref)
|
||||
assert.equal(await item.settled, 19)
|
||||
}
|
||||
for (let index = 0; index < 70; index++) {
|
||||
const next = liveCall(queue)
|
||||
current.release()
|
||||
await current.settled
|
||||
current = next
|
||||
}
|
||||
await collect()
|
||||
const retained = liveCount(refs)
|
||||
assert.equal(retained, candidate ? 0 : 8)
|
||||
assert.equal(queue.retainedCallBytes, 0)
|
||||
assert.equal(queue.queues.get('fixture').active, 1)
|
||||
assert.equal(queue.queues.get('fixture').foregroundHead, 8)
|
||||
assert.equal(queue.queues.get('fixture').backgroundHead, 5)
|
||||
current.release()
|
||||
await current.settled
|
||||
await collect()
|
||||
assert.equal(liveCount(refs), 0)
|
||||
assert.equal(queue.queues.size, 0)
|
||||
return {
|
||||
candidate,
|
||||
case: 'rolling-background-traffic',
|
||||
completedForegroundPayloads: 8,
|
||||
completedBackgroundCalls: 70,
|
||||
foregroundPayloadsStillRetained: retained,
|
||||
retainedAfterIdle: 0,
|
||||
everyBackgroundCallCompletes: true
|
||||
}
|
||||
}
|
||||
|
||||
async function checkOrderAndCompaction(Queue, candidate) {
|
||||
const queue = new Queue(1, 1)
|
||||
const blocker = liveCall(queue, 'terminal.send')
|
||||
const started = []
|
||||
const pending = []
|
||||
const controllers = []
|
||||
for (const lane of ['background', 'foreground']) {
|
||||
for (let index = 0; index < 70; index++) {
|
||||
const id = `${lane}:${index}`
|
||||
const controller = new AbortController()
|
||||
const promise = queue.enqueue(
|
||||
'fixture',
|
||||
lane === 'background' ? 'git.status' : 'terminal.send',
|
||||
async () => {
|
||||
started.push(id)
|
||||
return id
|
||||
},
|
||||
0,
|
||||
controller.signal
|
||||
)
|
||||
pending.push(
|
||||
promise.then(
|
||||
(value) => ({ value }),
|
||||
(error) => ({ error: error.name })
|
||||
)
|
||||
)
|
||||
controllers.push({ id, controller })
|
||||
}
|
||||
}
|
||||
const cancelled = new Set([
|
||||
'foreground:0',
|
||||
'foreground:35',
|
||||
'foreground:69',
|
||||
'background:0',
|
||||
'background:35',
|
||||
'background:69'
|
||||
])
|
||||
for (const { id, controller } of controllers) {
|
||||
if (cancelled.has(id)) {
|
||||
controller.abort()
|
||||
}
|
||||
}
|
||||
assert.equal(queue.queuedCallCount, 134)
|
||||
blocker.release()
|
||||
await blocker.settled
|
||||
const results = await Promise.all(pending)
|
||||
const expected = ['foreground', 'background']
|
||||
.flatMap((lane) => Array.from({ length: 70 }, (_, index) => `${lane}:${index}`))
|
||||
.filter((id) => !cancelled.has(id))
|
||||
assert.deepEqual(started, expected)
|
||||
assert.equal(results.filter((result) => result.error === 'AbortError').length, 6)
|
||||
assert.equal(results.filter((result) => result.value !== undefined).length, 134)
|
||||
await collect()
|
||||
assert.equal(queue.queuedCallCount, 0)
|
||||
assert.equal(queue.queues.size, 0)
|
||||
return {
|
||||
candidate,
|
||||
case: 'ordering-compaction-and-cancellation',
|
||||
completed: 134,
|
||||
cancelled: 6,
|
||||
foregroundBeforeBackground: true,
|
||||
fifoWithinEachLane: true,
|
||||
finalQueueCount: 0
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const reports = []
|
||||
for (const candidate of [false, true]) {
|
||||
const Queue = load(candidate)
|
||||
for (const lane of ['foreground', 'background']) {
|
||||
reports.push(await checkActiveAndCancelled(Queue, candidate, lane))
|
||||
reports.push(await checkFailure(Queue, candidate, lane))
|
||||
}
|
||||
reports.push(await checkCrossLaneTraffic(Queue, candidate))
|
||||
reports.push(await checkOrderAndCompaction(Queue, candidate))
|
||||
}
|
||||
const report = {
|
||||
node: process.version,
|
||||
electron: process.versions.electron ?? null,
|
||||
versions,
|
||||
proofSha256: sha(fs.readFileSync(__filename)),
|
||||
reports
|
||||
}
|
||||
const resultName = process.versions.electron
|
||||
? 'electron-extended-results.json'
|
||||
: 'extended-results.json'
|
||||
fs.writeFileSync(path.join(__dirname, resultName), `${JSON.stringify(report, null, 2)}\n`)
|
||||
console.log(JSON.stringify(report, null, 2))
|
||||
}
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
setTimeout(() => {
|
||||
console.error('fixture timeout')
|
||||
process.exit(2)
|
||||
}, 10000).unref()
|
||||
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"node": "v26.6.0",
|
||||
"electron": null,
|
||||
"versions": {
|
||||
"sourcePath": "src/shared/runtime-rpc-call-queue.ts",
|
||||
"baselineSha256": "45921658a10ffeefe0247673227aab643d1b5251d24aa460244fadd5480fc349",
|
||||
"fixedSha256": "6afe6eb8acd9025d227960fafef77ae205a455c7f680b15ad0db5cdfee18c71d",
|
||||
"matchingBaselineRefs": {
|
||||
"origin/main": "77cd61df396f25ec91ee2d5ddcbd1f55aa94f818",
|
||||
"v1.4.198": "e0826956fcfc532f5a1e55b5e081f2e57e553c43"
|
||||
}
|
||||
},
|
||||
"proofSha256": "af64f8b55f070d4a863ff6b8f456d518d977cc06400bdbbbba60eb2c5a455aa2",
|
||||
"reports": [
|
||||
{
|
||||
"candidate": false,
|
||||
"case": "active-and-cancelled",
|
||||
"lane": "foreground",
|
||||
"activeRetainedUntilSettlement": true,
|
||||
"cancelledReleasedBeforeActiveFinishes": true
|
||||
},
|
||||
{
|
||||
"candidate": false,
|
||||
"case": "synchronous-failure",
|
||||
"lane": "foreground",
|
||||
"retainedWhileOtherCallActive": 1,
|
||||
"retainedAfterIdle": 0
|
||||
},
|
||||
{
|
||||
"candidate": false,
|
||||
"case": "active-and-cancelled",
|
||||
"lane": "background",
|
||||
"activeRetainedUntilSettlement": true,
|
||||
"cancelledReleasedBeforeActiveFinishes": true
|
||||
},
|
||||
{
|
||||
"candidate": false,
|
||||
"case": "synchronous-failure",
|
||||
"lane": "background",
|
||||
"retainedWhileOtherCallActive": 1,
|
||||
"retainedAfterIdle": 0
|
||||
},
|
||||
{
|
||||
"candidate": false,
|
||||
"case": "rolling-background-traffic",
|
||||
"completedForegroundPayloads": 8,
|
||||
"completedBackgroundCalls": 70,
|
||||
"foregroundPayloadsStillRetained": 8,
|
||||
"retainedAfterIdle": 0,
|
||||
"everyBackgroundCallCompletes": true
|
||||
},
|
||||
{
|
||||
"candidate": false,
|
||||
"case": "ordering-compaction-and-cancellation",
|
||||
"completed": 134,
|
||||
"cancelled": 6,
|
||||
"foregroundBeforeBackground": true,
|
||||
"fifoWithinEachLane": true,
|
||||
"finalQueueCount": 0
|
||||
},
|
||||
{
|
||||
"candidate": true,
|
||||
"case": "active-and-cancelled",
|
||||
"lane": "foreground",
|
||||
"activeRetainedUntilSettlement": true,
|
||||
"cancelledReleasedBeforeActiveFinishes": true
|
||||
},
|
||||
{
|
||||
"candidate": true,
|
||||
"case": "synchronous-failure",
|
||||
"lane": "foreground",
|
||||
"retainedWhileOtherCallActive": 0,
|
||||
"retainedAfterIdle": 0
|
||||
},
|
||||
{
|
||||
"candidate": true,
|
||||
"case": "active-and-cancelled",
|
||||
"lane": "background",
|
||||
"activeRetainedUntilSettlement": true,
|
||||
"cancelledReleasedBeforeActiveFinishes": true
|
||||
},
|
||||
{
|
||||
"candidate": true,
|
||||
"case": "synchronous-failure",
|
||||
"lane": "background",
|
||||
"retainedWhileOtherCallActive": 0,
|
||||
"retainedAfterIdle": 0
|
||||
},
|
||||
{
|
||||
"candidate": true,
|
||||
"case": "rolling-background-traffic",
|
||||
"completedForegroundPayloads": 8,
|
||||
"completedBackgroundCalls": 70,
|
||||
"foregroundPayloadsStillRetained": 0,
|
||||
"retainedAfterIdle": 0,
|
||||
"everyBackgroundCallCompletes": true
|
||||
},
|
||||
{
|
||||
"candidate": true,
|
||||
"case": "ordering-compaction-and-cancellation",
|
||||
"completed": 134,
|
||||
"cancelled": 6,
|
||||
"foregroundBeforeBackground": true,
|
||||
"fifoWithinEachLane": true,
|
||||
"finalQueueCount": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
diff --git a/src/shared/runtime-rpc-call-queue.ts b/src/shared/runtime-rpc-call-queue.ts
|
||||
index dcf4015078..7a3c184260 100644
|
||||
--- a/src/shared/runtime-rpc-call-queue.ts
|
||||
+++ b/src/shared/runtime-rpc-call-queue.ts
|
||||
@@ -30,9 +30,9 @@ type QueuedRuntimeCall<T> = {
|
||||
type RuntimeCallQueue = {
|
||||
active: number
|
||||
backgroundActive: number
|
||||
- foreground: QueuedRuntimeCall<unknown>[]
|
||||
+ foreground: (QueuedRuntimeCall<unknown> | undefined)[]
|
||||
foregroundHead: number
|
||||
- background: QueuedRuntimeCall<unknown>[]
|
||||
+ background: (QueuedRuntimeCall<unknown> | undefined)[]
|
||||
backgroundHead: number
|
||||
}
|
||||
|
||||
@@ -202,6 +202,7 @@ export class RuntimeRpcCallQueuePool {
|
||||
return undefined
|
||||
}
|
||||
const call = queue.foreground[queue.foregroundHead]
|
||||
+ queue.foreground[queue.foregroundHead] = undefined
|
||||
queue.foregroundHead += 1
|
||||
this.queuedCallCount = Math.max(0, this.queuedCallCount - 1)
|
||||
this.compactForeground(queue)
|
||||
@@ -213,6 +214,7 @@ export class RuntimeRpcCallQueuePool {
|
||||
return undefined
|
||||
}
|
||||
const call = queue.background[queue.backgroundHead]
|
||||
+ queue.background[queue.backgroundHead] = undefined
|
||||
queue.backgroundHead += 1
|
||||
this.queuedCallCount = Math.max(0, this.queuedCallCount - 1)
|
||||
this.compactBackground(queue)
|
||||
@@ -223,8 +225,7 @@ export class RuntimeRpcCallQueuePool {
|
||||
if (queue.foregroundHead <= 32 || queue.foregroundHead * 2 < queue.foreground.length) {
|
||||
return
|
||||
}
|
||||
- // Why: large remote-runtime refresh bursts can queue many calls;
|
||||
- // head indexes avoid O(n) shift costs while compaction releases closures.
|
||||
+ // Head indexes avoid repeated shifts; compaction bounds the consumed prefix.
|
||||
queue.foreground.splice(0, queue.foregroundHead)
|
||||
queue.foregroundHead = 0
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const { createHash } = require('node:crypto')
|
||||
const { readFileSync } = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const Module = require('node:module')
|
||||
const esbuild = require('esbuild')
|
||||
const { applyPatch, parsePatch, reversePatch } = require('diff')
|
||||
const versions = require('./source-versions.json')
|
||||
|
||||
assert.equal(process.env.ORCA_BACKGROUND_LAUNCH, '1')
|
||||
assert.equal(typeof global.gc, 'function')
|
||||
const root = path.resolve(__dirname, '../../..')
|
||||
const readText = (file) => readFileSync(file, 'utf8').replace(/\r\n/g, '\n')
|
||||
const sha = (value) => createHash('sha256').update(value).digest('hex')
|
||||
const fixedSource = readText(path.join(root, versions.sourcePath))
|
||||
assert.equal(sha(fixedSource), versions.fixedSha256, 'Product source changed; review proof hashes')
|
||||
const patches = parsePatch(readText(path.join(__dirname, 'fix.patch')))
|
||||
assert.equal(patches.length, 1)
|
||||
const baselineSource = applyPatch(fixedSource, reversePatch(patches[0]))
|
||||
assert.notEqual(baselineSource, false)
|
||||
assert.equal(sha(baselineSource), versions.baselineSha256, 'Baseline reconstruction changed')
|
||||
|
||||
function load(candidate) {
|
||||
const build = esbuild.buildSync({
|
||||
stdin: {
|
||||
contents: candidate ? fixedSource : baselineSource,
|
||||
resolveDir: path.join(root, 'src/shared'),
|
||||
sourcefile: versions.sourcePath,
|
||||
loader: 'ts'
|
||||
},
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
bundle: true,
|
||||
packages: 'external',
|
||||
write: false
|
||||
})
|
||||
const filename = path.join(__dirname, 'bundled-queue.cjs')
|
||||
const loaded = new Module(filename, module)
|
||||
loaded.filename = filename
|
||||
loaded.paths = Module._nodeModulePaths(__dirname)
|
||||
loaded._compile(build.outputFiles[0].text, filename)
|
||||
return loaded.exports.RuntimeRpcCallQueuePool
|
||||
}
|
||||
|
||||
async function collect() {
|
||||
for (let round = 0; round < 3; round += 1) {
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
global.gc()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { load, collect, sha, versions, baselineSource }
|
||||
@@ -0,0 +1,109 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { load, collect, sha, versions } = require('./queue-source.cjs')
|
||||
async function postInput(queue, method, index) {
|
||||
const data = new Uint8Array(1024 * 1024)
|
||||
data[0] = index
|
||||
const ref = new WeakRef(data)
|
||||
await queue.enqueue('fixture', method, async () => data[0], data.byteLength)
|
||||
return ref
|
||||
}
|
||||
async function postResult(queue, method, index) {
|
||||
let ref
|
||||
await queue.enqueue('fixture', method, async () => {
|
||||
const data = new Uint8Array(1024 * 1024)
|
||||
data[0] = index
|
||||
ref = new WeakRef(data)
|
||||
return { data }
|
||||
})
|
||||
assert(ref)
|
||||
return ref
|
||||
}
|
||||
async function lifetime(Queue, candidate, kind, lane, completed, releaseByIdle = false) {
|
||||
const queue = new Queue(3, 2)
|
||||
const hold = Promise.withResolvers()
|
||||
const stuck = queue.enqueue('fixture', 'fixture.hold', () => hold.promise)
|
||||
const method = lane === 'background' ? 'git.status' : 'fixture.echo'
|
||||
const refs = []
|
||||
const before = process.memoryUsage()
|
||||
for (let i = 0; i < completed; i++) {
|
||||
refs.push(await (kind === 'input' ? postInput : postResult)(queue, method, i))
|
||||
}
|
||||
await collect()
|
||||
const retainedBeforeCompaction = refs.filter((ref) => ref.deref()).length
|
||||
const { historyLength, head } = (() => {
|
||||
const state = queue.queues.get('fixture')
|
||||
assert(state)
|
||||
return { historyLength: state[lane].length, head: state[`${lane}Head`] }
|
||||
})()
|
||||
assert.equal(queue.retainedCallBytes, 0)
|
||||
assert.equal(queue.queuedCallCount, 0)
|
||||
if (candidate || kind === 'input') {
|
||||
assert.equal(retainedBeforeCompaction, candidate ? 0 : completed)
|
||||
}
|
||||
const afterCompleted = process.memoryUsage()
|
||||
if (releaseByIdle) {
|
||||
hold.resolve(0)
|
||||
await stuck
|
||||
}
|
||||
const completionsUntilCompaction = releaseByIdle ? 0 : 33 - head
|
||||
for (let i = 0; i < completionsUntilCompaction; i++) {
|
||||
await queue.enqueue('fixture', method, async () => 0)
|
||||
}
|
||||
await collect()
|
||||
const retainedAfterCompaction = refs.filter((ref) => ref.deref()).length
|
||||
assert.equal(retainedAfterCompaction, 0)
|
||||
hold.resolve(0)
|
||||
await stuck
|
||||
await collect()
|
||||
assert.equal(queue.queues.size, 0)
|
||||
return {
|
||||
candidate,
|
||||
kind,
|
||||
lane,
|
||||
completed,
|
||||
releaseByIdle,
|
||||
historyLength,
|
||||
head,
|
||||
retainedBeforeCompaction,
|
||||
retainedAfterCompaction,
|
||||
activeCreditBytesAfterCompleted: 0,
|
||||
finalQueues: queue.queues.size,
|
||||
memoryDelta: {
|
||||
external: afterCompleted.external - before.external,
|
||||
heapUsed: afterCompleted.heapUsed - before.heapUsed
|
||||
}
|
||||
}
|
||||
}
|
||||
async function main() {
|
||||
const reports = []
|
||||
for (const candidate of [false, true]) {
|
||||
const Queue = load(candidate)
|
||||
for (const [kind, lane, completed, releaseByIdle] of [
|
||||
['input', 'foreground', 8, false],
|
||||
['input', 'background', 8, false],
|
||||
['input', 'foreground', 8, true],
|
||||
['result', 'foreground', 8, false]
|
||||
]) {
|
||||
reports.push(await lifetime(Queue, candidate, kind, lane, completed, releaseByIdle))
|
||||
console.log(JSON.stringify(reports.at(-1)))
|
||||
}
|
||||
}
|
||||
const resultName = process.versions.electron ? 'electron-results.json' : 'results.json'
|
||||
fs.writeFileSync(
|
||||
path.join(__dirname, resultName),
|
||||
`${JSON.stringify({ node: process.version, electron: process.versions.electron ?? null, versions, proofSha256: sha(fs.readFileSync(__filename)), reports }, null, 2)}\n`
|
||||
)
|
||||
}
|
||||
module.exports = { load, collect, sha }
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
setTimeout(() => {
|
||||
console.error('fixture timeout')
|
||||
process.exit(2)
|
||||
}, 10000).unref()
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { collect, sha } = require('./queue-source.cjs')
|
||||
|
||||
async function postResult(keepers) {
|
||||
let ref
|
||||
await new Promise((resolve) => {
|
||||
const payload = new Uint8Array(1024 * 1024)
|
||||
payload[0] = 19
|
||||
ref = new WeakRef(payload)
|
||||
keepers.push(resolve)
|
||||
resolve({ payload })
|
||||
})
|
||||
assert(ref)
|
||||
return ref
|
||||
}
|
||||
async function main() {
|
||||
const keepers = []
|
||||
const refs = []
|
||||
for (let index = 0; index < 8; index++) {
|
||||
refs.push(await postResult(keepers))
|
||||
}
|
||||
await collect()
|
||||
const retainedWithResolveFunctions = refs.filter((ref) => ref.deref() !== undefined).length
|
||||
keepers.length = 0
|
||||
await collect()
|
||||
const retainedAfterResolveFunctionsReleased = refs.filter(
|
||||
(ref) => ref.deref() !== undefined
|
||||
).length
|
||||
assert.equal(retainedAfterResolveFunctionsReleased, 0)
|
||||
const report = {
|
||||
node: process.version,
|
||||
electron: process.versions.electron ?? null,
|
||||
v8: process.versions.v8,
|
||||
proofSha256: sha(fs.readFileSync(__filename)),
|
||||
payloads: 8,
|
||||
bytesPerPayload: 1024 * 1024,
|
||||
retainedWithResolveFunctions,
|
||||
retainedAfterResolveFunctionsReleased
|
||||
}
|
||||
const resultName = process.versions.electron
|
||||
? 'electron-resolver-results.json'
|
||||
: 'resolver-results.json'
|
||||
fs.writeFileSync(path.join(__dirname, resultName), `${JSON.stringify(report, null, 2)}\n`)
|
||||
console.log(JSON.stringify(report, null, 2))
|
||||
}
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
setTimeout(() => {
|
||||
console.error('fixture timeout')
|
||||
process.exit(2)
|
||||
}, 10000).unref()
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"node": "v26.6.0",
|
||||
"electron": null,
|
||||
"v8": "14.6.202.34-node.26",
|
||||
"proofSha256": "725087b61b9d79bc2daff4fe45f52951c531dcf9d23137a4939508957848350e",
|
||||
"payloads": 8,
|
||||
"bytesPerPayload": 1048576,
|
||||
"retainedWithResolveFunctions": 0,
|
||||
"retainedAfterResolveFunctionsReleased": 0
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
{
|
||||
"node": "v26.6.0",
|
||||
"electron": null,
|
||||
"versions": {
|
||||
"sourcePath": "src/shared/runtime-rpc-call-queue.ts",
|
||||
"baselineSha256": "45921658a10ffeefe0247673227aab643d1b5251d24aa460244fadd5480fc349",
|
||||
"fixedSha256": "6afe6eb8acd9025d227960fafef77ae205a455c7f680b15ad0db5cdfee18c71d",
|
||||
"matchingBaselineRefs": {
|
||||
"origin/main": "77cd61df396f25ec91ee2d5ddcbd1f55aa94f818",
|
||||
"v1.4.198": "e0826956fcfc532f5a1e55b5e081f2e57e553c43"
|
||||
}
|
||||
},
|
||||
"proofSha256": "79e415e5f7de6516b4e4bd8fc8ab0c6d0ac139efe996b8c39ef4cf7b8659bacb",
|
||||
"reports": [
|
||||
{
|
||||
"candidate": false,
|
||||
"kind": "input",
|
||||
"lane": "foreground",
|
||||
"completed": 8,
|
||||
"releaseByIdle": false,
|
||||
"historyLength": 9,
|
||||
"head": 9,
|
||||
"retainedBeforeCompaction": 8,
|
||||
"retainedAfterCompaction": 0,
|
||||
"activeCreditBytesAfterCompleted": 0,
|
||||
"finalQueues": 0,
|
||||
"memoryDelta": {
|
||||
"external": 8378970,
|
||||
"heapUsed": -555304
|
||||
}
|
||||
},
|
||||
{
|
||||
"candidate": false,
|
||||
"kind": "input",
|
||||
"lane": "background",
|
||||
"completed": 8,
|
||||
"releaseByIdle": false,
|
||||
"historyLength": 8,
|
||||
"head": 8,
|
||||
"retainedBeforeCompaction": 8,
|
||||
"retainedAfterCompaction": 0,
|
||||
"activeCreditBytesAfterCompleted": 0,
|
||||
"finalQueues": 0,
|
||||
"memoryDelta": {
|
||||
"external": 8388608,
|
||||
"heapUsed": 6648
|
||||
}
|
||||
},
|
||||
{
|
||||
"candidate": false,
|
||||
"kind": "input",
|
||||
"lane": "foreground",
|
||||
"completed": 8,
|
||||
"releaseByIdle": true,
|
||||
"historyLength": 9,
|
||||
"head": 9,
|
||||
"retainedBeforeCompaction": 8,
|
||||
"retainedAfterCompaction": 0,
|
||||
"activeCreditBytesAfterCompleted": 0,
|
||||
"finalQueues": 0,
|
||||
"memoryDelta": {
|
||||
"external": 8388608,
|
||||
"heapUsed": 192
|
||||
}
|
||||
},
|
||||
{
|
||||
"candidate": false,
|
||||
"kind": "result",
|
||||
"lane": "foreground",
|
||||
"completed": 8,
|
||||
"releaseByIdle": false,
|
||||
"historyLength": 9,
|
||||
"head": 9,
|
||||
"retainedBeforeCompaction": 0,
|
||||
"retainedAfterCompaction": 0,
|
||||
"activeCreditBytesAfterCompleted": 0,
|
||||
"finalQueues": 0,
|
||||
"memoryDelta": {
|
||||
"external": 0,
|
||||
"heapUsed": 7744
|
||||
}
|
||||
},
|
||||
{
|
||||
"candidate": true,
|
||||
"kind": "input",
|
||||
"lane": "foreground",
|
||||
"completed": 8,
|
||||
"releaseByIdle": false,
|
||||
"historyLength": 9,
|
||||
"head": 9,
|
||||
"retainedBeforeCompaction": 0,
|
||||
"retainedAfterCompaction": 0,
|
||||
"activeCreditBytesAfterCompleted": 0,
|
||||
"finalQueues": 0,
|
||||
"memoryDelta": {
|
||||
"external": -9748,
|
||||
"heapUsed": -9552
|
||||
}
|
||||
},
|
||||
{
|
||||
"candidate": true,
|
||||
"kind": "input",
|
||||
"lane": "background",
|
||||
"completed": 8,
|
||||
"releaseByIdle": false,
|
||||
"historyLength": 8,
|
||||
"head": 8,
|
||||
"retainedBeforeCompaction": 0,
|
||||
"retainedAfterCompaction": 0,
|
||||
"activeCreditBytesAfterCompleted": 0,
|
||||
"finalQueues": 0,
|
||||
"memoryDelta": {
|
||||
"external": 0,
|
||||
"heapUsed": -3808
|
||||
}
|
||||
},
|
||||
{
|
||||
"candidate": true,
|
||||
"kind": "input",
|
||||
"lane": "foreground",
|
||||
"completed": 8,
|
||||
"releaseByIdle": true,
|
||||
"historyLength": 9,
|
||||
"head": 9,
|
||||
"retainedBeforeCompaction": 0,
|
||||
"retainedAfterCompaction": 0,
|
||||
"activeCreditBytesAfterCompleted": 0,
|
||||
"finalQueues": 0,
|
||||
"memoryDelta": {
|
||||
"external": 0,
|
||||
"heapUsed": -4616
|
||||
}
|
||||
},
|
||||
{
|
||||
"candidate": true,
|
||||
"kind": "result",
|
||||
"lane": "foreground",
|
||||
"completed": 8,
|
||||
"releaseByIdle": false,
|
||||
"historyLength": 9,
|
||||
"head": 9,
|
||||
"retainedBeforeCompaction": 0,
|
||||
"retainedAfterCompaction": 0,
|
||||
"activeCreditBytesAfterCompleted": 0,
|
||||
"finalQueues": 0,
|
||||
"memoryDelta": {
|
||||
"external": 0,
|
||||
"heapUsed": 2608
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"sourcePath": "src/shared/runtime-rpc-call-queue.ts",
|
||||
"baselineSha256": "45921658a10ffeefe0247673227aab643d1b5251d24aa460244fadd5480fc349",
|
||||
"fixedSha256": "6afe6eb8acd9025d227960fafef77ae205a455c7f680b15ad0db5cdfee18c71d",
|
||||
"matchingBaselineRefs": {
|
||||
"origin/main": "77cd61df396f25ec91ee2d5ddcbd1f55aa94f818",
|
||||
"v1.4.198": "e0826956fcfc532f5a1e55b5e081f2e57e553c43"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { RuntimeRpcCallQueuePool } from './runtime-rpc-call-queue'
|
||||
|
||||
async function collect(): Promise<void> {
|
||||
if (!('gc' in globalThis) || typeof globalThis.gc !== 'function') {
|
||||
throw new Error('The test runner must enable --expose-gc')
|
||||
}
|
||||
for (let round = 0; round < 3; round += 1) {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
globalThis.gc()
|
||||
}
|
||||
}
|
||||
|
||||
function gate(): { promise: Promise<void>; release: () => void } {
|
||||
let release = (): void => {}
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
return { promise, release }
|
||||
}
|
||||
|
||||
function enqueuePayload(
|
||||
queue: RuntimeRpcCallQueuePool,
|
||||
method: string,
|
||||
wait: Promise<void>,
|
||||
signal?: AbortSignal
|
||||
): { ref: WeakRef<Uint8Array>; settled: Promise<number> } {
|
||||
const payload = new Uint8Array(1024 * 1024)
|
||||
payload[0] = 19
|
||||
return {
|
||||
ref: new WeakRef(payload),
|
||||
settled: queue.enqueue(
|
||||
'runtime-a',
|
||||
method,
|
||||
async () => {
|
||||
await wait
|
||||
return payload[0]!
|
||||
},
|
||||
payload.byteLength,
|
||||
signal
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function completePayload(
|
||||
queue: RuntimeRpcCallQueuePool,
|
||||
method: string
|
||||
): Promise<WeakRef<Uint8Array>> {
|
||||
const { ref, settled } = enqueuePayload(queue, method, Promise.resolve())
|
||||
expect(await settled).toBe(19)
|
||||
return ref
|
||||
}
|
||||
|
||||
describe('runtime RPC completed-call retention', () => {
|
||||
it.each(['terminal.send', 'git.status'])(
|
||||
'releases completed %s inputs while another call keeps the selector active',
|
||||
async (method) => {
|
||||
const queue = new RuntimeRpcCallQueuePool(3, 2)
|
||||
const blocker = gate()
|
||||
const active = queue.enqueue('runtime-a', 'terminal.send', () => blocker.promise)
|
||||
try {
|
||||
const refs: WeakRef<Uint8Array>[] = []
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
refs.push(await completePayload(queue, method))
|
||||
}
|
||||
await collect()
|
||||
expect(refs.filter((ref) => ref.deref() !== undefined)).toHaveLength(0)
|
||||
} finally {
|
||||
blocker.release()
|
||||
await active
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['terminal.send', 'git.status'])(
|
||||
'keeps an active %s input and releases a cancelled queued input',
|
||||
async (method) => {
|
||||
const queue = new RuntimeRpcCallQueuePool(1, 1)
|
||||
const blocker = gate()
|
||||
const active = enqueuePayload(queue, method, blocker.promise)
|
||||
const controller = new AbortController()
|
||||
const queued = enqueuePayload(queue, method, Promise.resolve(), controller.signal)
|
||||
const rejected = expect(queued.settled).rejects.toMatchObject({ name: 'AbortError' })
|
||||
try {
|
||||
await collect()
|
||||
expect(active.ref.deref()?.[0]).toBe(19)
|
||||
expect(queued.ref.deref()?.[0]).toBe(19)
|
||||
controller.abort()
|
||||
await rejected
|
||||
await collect()
|
||||
expect(active.ref.deref()?.[0]).toBe(19)
|
||||
expect(queued.ref.deref() === undefined).toBe(true)
|
||||
} finally {
|
||||
controller.abort()
|
||||
blocker.release()
|
||||
await Promise.allSettled([active.settled, queued.settled])
|
||||
}
|
||||
expect(await active.settled).toBe(19)
|
||||
await collect()
|
||||
expect(active.ref.deref() === undefined).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it('releases old foreground inputs during continuous finite background calls', async () => {
|
||||
const queue = new RuntimeRpcCallQueuePool(3, 2)
|
||||
const startBackground = (): { release: () => void; settled: Promise<void> } => {
|
||||
const wait = gate()
|
||||
return {
|
||||
release: wait.release,
|
||||
settled: queue.enqueue('runtime-a', 'git.status', () => wait.promise)
|
||||
}
|
||||
}
|
||||
let active = startBackground()
|
||||
try {
|
||||
const ref = await completePayload(queue, 'terminal.send')
|
||||
for (let index = 0; index < 70; index += 1) {
|
||||
const next = startBackground()
|
||||
active.release()
|
||||
await active.settled
|
||||
active = next
|
||||
}
|
||||
await collect()
|
||||
expect(ref.deref() === undefined).toBe(true)
|
||||
} finally {
|
||||
active.release()
|
||||
await active.settled
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves lane order and queued cancellation across compaction', async () => {
|
||||
const queue = new RuntimeRpcCallQueuePool(1, 1)
|
||||
const blocker = gate()
|
||||
const active = queue.enqueue('runtime-a', 'terminal.send', () => blocker.promise)
|
||||
const started: string[] = []
|
||||
const pending: Promise<string>[] = []
|
||||
const cancelled = new Set([
|
||||
'foreground:0',
|
||||
'foreground:35',
|
||||
'foreground:69',
|
||||
'background:0',
|
||||
'background:35',
|
||||
'background:69'
|
||||
])
|
||||
for (const lane of ['background', 'foreground']) {
|
||||
for (let index = 0; index < 70; index += 1) {
|
||||
const id = `${lane}:${index}`
|
||||
const controller = new AbortController()
|
||||
const settled = queue.enqueue(
|
||||
'runtime-a',
|
||||
lane === 'background' ? 'git.status' : 'terminal.send',
|
||||
async () => {
|
||||
started.push(id)
|
||||
return id
|
||||
},
|
||||
0,
|
||||
controller.signal
|
||||
)
|
||||
if (cancelled.has(id)) {
|
||||
pending.push(
|
||||
settled.catch((error: unknown) => {
|
||||
expect(error).toMatchObject({ name: 'AbortError' })
|
||||
return 'cancelled'
|
||||
})
|
||||
)
|
||||
controller.abort()
|
||||
} else {
|
||||
pending.push(settled)
|
||||
}
|
||||
}
|
||||
}
|
||||
blocker.release()
|
||||
await active
|
||||
const results = await Promise.all(pending)
|
||||
const expected = ['foreground', 'background']
|
||||
.flatMap((lane) => Array.from({ length: 70 }, (_, index) => `${lane}:${index}`))
|
||||
.filter((id) => !cancelled.has(id))
|
||||
expect(started).toEqual(expected)
|
||||
expect(results.filter((value) => value === 'cancelled')).toHaveLength(6)
|
||||
expect(await queue.enqueue('runtime-a', 'terminal.send', async () => 'recovered')).toBe(
|
||||
'recovered'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -30,9 +30,9 @@ type QueuedRuntimeCall<T> = {
|
||||
type RuntimeCallQueue = {
|
||||
active: number
|
||||
backgroundActive: number
|
||||
foreground: QueuedRuntimeCall<unknown>[]
|
||||
foreground: (QueuedRuntimeCall<unknown> | undefined)[]
|
||||
foregroundHead: number
|
||||
background: QueuedRuntimeCall<unknown>[]
|
||||
background: (QueuedRuntimeCall<unknown> | undefined)[]
|
||||
backgroundHead: number
|
||||
}
|
||||
|
||||
@@ -202,6 +202,7 @@ export class RuntimeRpcCallQueuePool {
|
||||
return undefined
|
||||
}
|
||||
const call = queue.foreground[queue.foregroundHead]
|
||||
queue.foreground[queue.foregroundHead] = undefined
|
||||
queue.foregroundHead += 1
|
||||
this.queuedCallCount = Math.max(0, this.queuedCallCount - 1)
|
||||
this.compactForeground(queue)
|
||||
@@ -213,6 +214,7 @@ export class RuntimeRpcCallQueuePool {
|
||||
return undefined
|
||||
}
|
||||
const call = queue.background[queue.backgroundHead]
|
||||
queue.background[queue.backgroundHead] = undefined
|
||||
queue.backgroundHead += 1
|
||||
this.queuedCallCount = Math.max(0, this.queuedCallCount - 1)
|
||||
this.compactBackground(queue)
|
||||
@@ -223,8 +225,7 @@ export class RuntimeRpcCallQueuePool {
|
||||
if (queue.foregroundHead <= 32 || queue.foregroundHead * 2 < queue.foreground.length) {
|
||||
return
|
||||
}
|
||||
// Why: large remote-runtime refresh bursts can queue many calls;
|
||||
// head indexes avoid O(n) shift costs while compaction releases closures.
|
||||
// Head indexes avoid repeated shifts; compaction bounds the consumed prefix.
|
||||
queue.foreground.splice(0, queue.foregroundHead)
|
||||
queue.foregroundHead = 0
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user