cloudflare / cloudflare/capnweb
`getImport()` returns the id of an already-settled import, sending a released id to the peer
- Dominant language
- TypeScript
- Stars
- 4k
- Forks
- 143
- Avg merge
- 4d 6h
- Merged PRs (30d)
- 7
Description
### Summary
`getImport()` returns an import's id without checking whether that import has already settled and been released. Passing a settled `RpcPromise` as an argument therefore puts a dead id on the wire, and the peer aborts the entire session.
Reproduced on `0.12.0` (also on `0.10.0`) with both peers on identical code, so version skew is not required.
### Repro
```js
import { RpcSession, RpcTarget } from 'capnweb'
// in-memory transport pair, logging every frame
const mk = (label) => { const q = []; let w = null
return { push: (m) => { if (w) { const r = w; w = null; r(m) } else q.push(m) },
t: { send(m) { console.log(`${label} ->`, m); peer[label === 'client' ? 'server' : 'client'].push(m) },
receive() { return q.length ? Promise.resolve(q.shift()) : new Promise(r => { w = r }) },
abort(e) { console.log(`${label} transport abort:`, String(e)) } } }
}
const c = mk('client'), s = mk('server'); const peer = { client: c, server: s }
class Api extends RpcTarget {
getThing() { return { n: 1 } }
use(x) { return { echoed: x } }
}
const server = new RpcSession(s.t, new Api())
const client = new RpcSession(c.t)
const api = client.getRemoteMain()
const p = api.getThing()
const v = await p
await api.use(v) // fine
await api.use(p) // passes the same promise object, not its value
await api.getThing() // session is gone
```
### Actual output
```
--- await, then pass the VALUE ---
client -> ["push",["pipeline",0,["getThing"],[]]]
client -> ["pull",1]
server -> ["resolve",1,{"n":1}]
client -> ["release",1,1]
v = { n: 1 }
client -> ["push",["pipeline",0,["use"],[{"n":1}]]]
client -> ["pull",2]
server -> ["resolve",2,{"echoed":{"n":1}}]
client -> ["release",2,1]
use(v) = { echoed: { n: 1 } }
--- await, then pass the same PROMISE object ---
client -> ["push",["pipeline",0,["use"],[["pipeline",1]]]]
client -> ["pull",3]
server -> ["abort",["error","Error","no such entry on exports table: 1"]]
server transport abort: Error: no such entry on exports table: 1
client transport abort: Error: no such entry on exports table: 1
use(p) THREW: no such entry on exports table: 1
--- is the session still usable? ---
SESSION DEAD: no such entry on exports table: 1
```
The `use(p)` frame is the bug: `["pipeline",1]` names import `1`, which the client itself released two frames earlier with `["release",1,1]`.
### Expected
Passing a settled `RpcPromise` as an argument should either resolve locally from the value already held, or fail that one call. It should not end the session. Note that passing the awaited *value* (`use(v)`) has always worked; passing the *promise object* (`use(p)`) is the trigger, and the two are easy to confuse in application code.
### Cause
This looks like a missed case in an established pattern rather than a missing pattern.
`ImportTableEntry.resolve()` stores the value in `this.resolution` and immediately calls `this.sendRelease()` (`dist/index.js:1750`, `:1756`), which sends the release frame and zeroes `remoteRefcount` (`:1806`). Both sides then correctly agree the id is dead. The id itself has to survive settlement, because `sendRelease()` reads `this.importId` to name what it releases. What should not survive is its *use*.
Five accessors already know this and operate on the settled value instead of the wire:
| accessor | line (0.12.0 `dist/index.js`) |
|---|---|
| `call` | `:1839` |
| `stream` | `:1844` |
| `map` | `:1851` |
| `get` | `:1856` |
| `pull` | `:1865` |
`getImport()` (`:2036`) does not. It asks only whether the hook belongs to this session:
```js
getImport(hook) {
if (hook instanceof RpcImportHook && hook.entry && hook.entry.session === this) return hook.entry.importId
else return
}
```
The resulting frame is fatal on the peer because `evaluateImpl` throws `no such entry on exports table` (`:1586`, `:1618`, `:1628`) inside `readLoop`, whose only error handler is session-wide: `this.readLoop().catch((err) => this.abort(err))` (`:1929`). That second half is #266, filed separately since it is a distinct decision; this issue is about not emitting the bad frame in the first place.
### Suggested fix
Extend the same `entry.resolution` gate to `getImport()`, so a settled entry withholds its id and the caller's existing `undefined` path takes over (the serializer calls it at `:1329` and already branches on `importId !== void 0` at `:1330`, which is the site that emits the `["pipeline", importId, ...]` seen above).
`awaitResolution()` (`:1772`) has the identical shape, calling `sendPull(this.importId)` without testing `this.resolution`. It appears unreachable in that state: its only two callers are `RpcImportHook.pull()`, which returns on `entry.resolution` one line earlier at `:1865`, and the `sendStream` path at `:2162`, whose entry is constructed with `pulling = true` so the constructor seeds `activePull` and the `sendPull` branch never runs. Guarding it costs one line and cannot be covered by a test for that reason. Worth flagging either way, since the shape remains.
### Impact
Six users across macOS and Windows since 2026-07-31, in an app where one session multiplexes every backend service, so a single occurrence costs the user every service at once and forces a reload.
Happy to open a PR if the approach looks right.
Contributor guide
Assessment
This issue has not been assessed yet.