Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions packages/collections/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ lastSeenByUser.get(userId); // undefined once ttlMs has elapsed

This is the first primitive in the package. `apps/hub/src/launch-caches.ts`'s
`BoundedCache` (size-capped LRU, no TTL) is a sibling that predates this
package — CL-7229 and CL-7223 are expected to either consume
`createExpiringMap` directly or contribute the size-capped-LRU shape here
so `BoundedCache` can retire in favor of one place for this problem.
package. `@corbits/folded-runs`' `createCryptoProviderCache` now consumes
`createExpiringMap` directly (CL-7223); CL-7229 is expected to either do
the same or contribute the size-capped-LRU shape here so `BoundedCache`
can retire in favor of one place for this problem.
8 changes: 5 additions & 3 deletions packages/folded-runs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@ or a host-specific package such as `@corbits/chat`.
imported from `@intx/hub-api`'s hub-api-internal helper.
- **Crypto provider caching** (`./src/crypto-cache.ts`) —
`createCryptoProviderCache` mints one `CryptoProvider` per cache key
(a workbench id, an instance id, ...) and reuses it for the cache's
lifetime; never evicted, since a key going momentarily unreachable does
not mean it is gone for good.
(a workbench id, an instance id, ...) and reuses it while the key stays
in active use, via `@corbits/collections`' `createExpiringMap` with a
7-day idle ttl refreshed on every access (CL-7223): a key going
momentarily unreachable (idle sleep, a sweep) does not evict it, so
only a key nobody has asked for in a week is treated as gone for good.
- **Run lookups** (`./src/runs.ts`) — resolving a run by id or address, and
bridging a run's principal to its live session via the shared-principal
bridge.
Expand Down
1 change: 1 addition & 0 deletions packages/folded-runs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
},
"dependencies": {
"@corbits/agent-runtime": "workspace:*",
"@corbits/collections": "workspace:*",
"@intx/crypto": "0.3.0",
"@intx/db": "workspace:*",
"@intx/hub-api": "workspace:*",
Expand Down
64 changes: 64 additions & 0 deletions packages/folded-runs/src/crypto-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, expect, test } from "bun:test";
import { createCryptoProviderCache } from "./crypto-cache";

/** A controllable clock: advances only when the test tells it to, so
* eviction timing is asserted exactly rather than raced against a real
* timer. */
function fakeClock(startAt = 0) {
let now = startAt;
return {
now: () => now,
advance(ms: number) {
now += ms;
},
};
}

describe("createCryptoProviderCache", () => {
test("reuses the same provider for a key accessed within its ttl", async () => {
const clock = fakeClock();
const cache = createCryptoProviderCache({ ttlMs: 1_000, now: clock.now });

const first = await cache.get("workbench-1");
clock.advance(999);
const second = await cache.get("workbench-1");

expect(second).toBe(first);
});

test("mints a fresh provider once a key has gone untouched past its ttl", async () => {
const clock = fakeClock();
const cache = createCryptoProviderCache({ ttlMs: 1_000, now: clock.now });

const first = await cache.get("workbench-1");
clock.advance(1_000);
const second = await cache.get("workbench-1");

expect(second).not.toBe(first);
expect(second.getPublicKey()).not.toEqual(first.getPublicKey());
});

test("an access refreshes the ttl, so a key in steady use never expires", async () => {
const clock = fakeClock();
const cache = createCryptoProviderCache({ ttlMs: 1_000, now: clock.now });

const first = await cache.get("workbench-1");
clock.advance(600);
await cache.get("workbench-1"); // refreshes the ttl
clock.advance(600);
const third = await cache.get("workbench-1");

// 1200ms since the first access, but only 600ms since the refresh.
expect(third).toBe(first);
});

test("distinct keys mint distinct providers", async () => {
const cache = createCryptoProviderCache();

const a = await cache.get("workbench-1");
const b = await cache.get("workbench-2");

expect(a).not.toBe(b);
expect(a.getPublicKey()).not.toEqual(b.getPublicKey());
});
});
46 changes: 32 additions & 14 deletions packages/folded-runs/src/crypto-cache.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,50 @@
// One `CryptoProvider` per cache key, minted once and reused for the
// cache's lifetime — mirroring the per-instance signing-key cache the
// One `CryptoProvider` per cache key, minted once and reused while the
// key stays active — mirroring the per-instance signing-key cache the
// platform's own mail route keeps. A caller picks its own key (a
// workbench id, an instance id, ...); this module knows nothing about
// what the key means.
import { createEd25519Crypto, generateKeyPair } from "@intx/crypto";
import { createExpiringMap } from "@corbits/collections";
import type { CryptoProvider } from "@intx/types/runtime";

export type CryptoProviderCache = {
get(key: string): Promise<CryptoProvider>;
};

export function createCryptoProviderCache(): CryptoProviderCache {
// Never evicted: a key going momentarily unreachable (idle sleep, a
// sweep) does not mean it is gone for good, so tearing this down on
// that signal would rotate its signing key on the next wake for no
// reason. Grows only with the number of distinct keys this process
// ever mints a provider for, not by traffic.
const providers = new Map<string, Promise<CryptoProvider>>();
/** A key untouched for this long is treated as gone for good rather
* than merely idle: an idle-sleep sweep or a long weekend away is
* routinely shorter than this, so a re-wake almost never rotates its
* signing key; only a workbench/instance nobody has come back to in a
* week does. Re-minting is cheap (one Ed25519 keypair generation) and
* nothing in this codebase persists or re-checks a signing key's
* public half against an earlier value, so a rare rotation for a truly
* abandoned key is not a correctness risk. */
const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000;

export function createCryptoProviderCache(options?: {
readonly ttlMs?: number;
readonly now?: () => number;
}): CryptoProviderCache {
const providers = createExpiringMap<string, Promise<CryptoProvider>>({
ttlMs: options?.ttlMs ?? DEFAULT_TTL_MS,
...(options?.now !== undefined ? { now: options.now } : {}),
});

return {
get(key: string): Promise<CryptoProvider> {
let pending = providers.get(key);
if (pending !== undefined) return pending;
pending = generateKeyPair().then((keyPair) =>
const pending = providers.get(key);
if (pending !== undefined) {
// Touch the entry so a key in active use never expires out
// from under it — only a key nobody has asked for within a
// full ttl window is treated as abandoned.
providers.set(key, pending);
return pending;
}
const minted = generateKeyPair().then((keyPair) =>
createEd25519Crypto(keyPair),
);
providers.set(key, pending);
return pending;
providers.set(key, minted);
return minted;
},
};
}
Loading