codehost 0.23.0 → 0.23.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## [0.23.2](https://github.com/snomiao/codehost/compare/v0.23.1...v0.23.2) (2026-06-16)
2
+
3
+
4
+ ### Performance Improvements
5
+
6
+ * **cli:** throttle meta pushes to the signaling room ([654ee10](https://github.com/snomiao/codehost/commit/654ee1088bc88f95ca6b57545fa477ccb77b994f))
7
+
8
+ ## [0.23.1](https://github.com/snomiao/codehost/compare/v0.23.0...v0.23.1) (2026-06-16)
9
+
10
+
11
+ ### Performance Improvements
12
+
13
+ * **signal:** exponential, unbounded backoff on the room sweep alarm ([fde1c43](https://github.com/snomiao/codehost/commit/fde1c43aa0dbc9274abb645ae43fc7cbd6712ee0))
14
+
1
15
  # [0.23.0](https://github.com/snomiao/codehost/compare/v0.22.1...v0.23.0) (2026-06-15)
2
16
 
3
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codehost",
3
- "version": "0.23.0",
3
+ "version": "0.23.2",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -56,6 +56,13 @@ export interface RunServerOptions {
56
56
  /** How often a daemon re-enumerates its workspaces (manual clones show up). */
57
57
  const META_REFRESH_MS = 60_000;
58
58
 
59
+ /** Floor between meta pushes to the room. `serve` re-evaluates meta every ~3s so
60
+ * live agent titles stay fresh, but each push is a billable DO request that
61
+ * fans out to every peer — so coalesce bursts (a churning agent title) into at
62
+ * most one push per this interval. The first change in a quiet period goes out
63
+ * immediately; rapid follow-ups ride a single trailing push. */
64
+ const MIN_META_PUSH_MS = 15_000;
65
+
59
66
  /**
60
67
  * Foreground server loop shared by `serve`, `dev`, and `expose`: register in the
61
68
  * signaling room with the given meta and bridge each client's data channel to a
@@ -113,15 +120,36 @@ export async function runServer(opts: RunServerOptions): Promise<never> {
113
120
  onSignal: (from, data) => rtc.handleSignal(from, data),
114
121
  });
115
122
 
116
- // Re-advertise when the workspace set changes (provision, manual clone).
117
- let lastMeta = JSON.stringify(opts.meta);
123
+ // Re-advertise when the workspace set changes (provision, manual clone),
124
+ // throttled so a burst of changes is at most one room push per MIN_META_PUSH_MS.
125
+ let sentMeta = JSON.stringify(opts.meta); // last meta the room actually has
126
+ let lastPushAt = 0; // ms of the last updateMeta send (0 = none since connect)
127
+ let pushTimer: ReturnType<typeof setTimeout> | null = null;
128
+ const sendMeta = (meta: PeerMeta) => {
129
+ const s = JSON.stringify(meta);
130
+ if (s === sentMeta) return; // nothing new since the last push
131
+ sentMeta = s;
132
+ lastPushAt = Date.now();
133
+ client.updateMeta(meta);
134
+ };
118
135
  const refreshMeta = () => {
119
136
  if (!opts.refreshMeta) return;
120
137
  const meta = opts.refreshMeta();
121
- const s = JSON.stringify(meta);
122
- if (s === lastMeta) return;
123
- lastMeta = s;
124
- client.updateMeta(meta);
138
+ if (JSON.stringify(meta) === sentMeta) return; // unchanged — nothing to do
139
+ const wait = MIN_META_PUSH_MS - (Date.now() - lastPushAt);
140
+ if (wait <= 0) {
141
+ // Cooldown elapsed: push the leading change now, dropping any pending one.
142
+ if (pushTimer) clearTimeout(pushTimer);
143
+ pushTimer = null;
144
+ sendMeta(meta);
145
+ } else if (!pushTimer) {
146
+ // Within the cooldown: schedule one trailing push that re-reads the
147
+ // freshest meta when it fires, so coalesced changes all ship at once.
148
+ pushTimer = setTimeout(() => {
149
+ pushTimer = null;
150
+ sendMeta(opts.refreshMeta!());
151
+ }, wait);
152
+ }
125
153
  };
126
154
  const provision: ProvisionDeps | undefined = opts.provision
127
155
  ? { ...opts.provision, onProvisioned: refreshMeta }
package/worker/room.ts CHANGED
@@ -16,16 +16,33 @@ interface Attachment {
16
16
  lastSeen: number;
17
17
  }
18
18
 
19
- /** How often the room scans for dead sockets, and how long a socket may go
20
- * silent before eviction. Clients heartbeat every ~25s (HEARTBEAT_MS in
21
- * signaling-client.ts); allow ~2 misses, so a crashed peer drops out within
22
- * ~65-85s. Every sweep alarm and every ping is a billable DO request, so both
23
- * cadences are deliberately slow — a hidden Chrome tab's throttled timers
24
- * (1/min) must still beat STALE_MS, or background tabs churn evict/reconnect
25
- * cycles all day. */
26
- const SWEEP_MS = 20_000;
19
+ /** How long a socket may go silent before eviction. Clients heartbeat every
20
+ * ~25s (HEARTBEAT_MS in signaling-client.ts); allow ~2 misses, so a crashed
21
+ * peer drops out within ~65s+. A hidden Chrome tab's throttled timers (1/min)
22
+ * must still beat STALE_MS, or background tabs churn evict/reconnect all day. */
27
23
  const STALE_MS = 65_000;
28
24
 
25
+ /** The sweep alarm scans for dead sockets, and every firing is a billable DO
26
+ * request. A room with one always-on daemon never goes idle, so a fixed-cadence
27
+ * sweep would wake the DO forever — the dominant signaling cost. Instead the
28
+ * interval backs off exponentially with NO upper bound: it starts at
29
+ * SWEEP_MIN_MS so a just-changed room evicts promptly, doubles after every no-op
30
+ * sweep, and resets to the floor whenever a peer joins or a stale socket is
31
+ * evicted. So a long-stable room's sweep cost trends to zero — past
32
+ * SWEEP_STOP_MS it stops arming the alarm entirely. The trade-off: a
33
+ * hard-killed peer can then linger until the edge notices the dead socket or
34
+ * someone new joins the room (cosmetic — a graceful close still evicts
35
+ * immediately via webSocketClose). The interval is persisted (DO storage) so it
36
+ * survives hibernation between alarms. */
37
+ const SWEEP_MIN_MS = 20_000;
38
+ const SWEEP_MAX_MS = Infinity;
39
+ /** Overflow/sanity guard for the unbounded backoff: once the doubled interval
40
+ * passes a day, stop arming the sweep rather than hand setAlarm an ever-growing
41
+ * (eventually non-finite) timestamp. A peer join revives sweeping at the floor
42
+ * via ensureSweep, so phantom cleanup resumes whenever the room is used again. */
43
+ const SWEEP_STOP_MS = 24 * 60 * 60 * 1000;
44
+ const SWEEP_KEY = "sweepMs";
45
+
29
46
  /**
30
47
  * One Durable Object instance per token-room. Holds the live WebSocket
31
48
  * connections, keeps a registry of who is present, and relays WebRTC signals
@@ -117,9 +134,17 @@ export class Room implements DurableObject {
117
134
  }
118
135
  }
119
136
  if (evicted) this.broadcastPeers();
120
- // Keep sweeping while anyone is connected; let idle rooms go quiet.
121
- if (this.state.getWebSockets().length > 0) {
122
- await this.state.storage.setAlarm(now + SWEEP_MS);
137
+ // Idle room: stop sweeping and let the DO hibernate.
138
+ if (this.state.getWebSockets().length === 0) return;
139
+ // Stable sweep -> double the interval (capped); an eviction means the room is
140
+ // changing, so reset to the floor and stay vigilant.
141
+ const prev = (await this.state.storage.get<number>(SWEEP_KEY)) ?? SWEEP_MIN_MS;
142
+ const next = evicted ? SWEEP_MIN_MS : Math.min(prev * 2, SWEEP_MAX_MS);
143
+ await this.state.storage.put(SWEEP_KEY, next);
144
+ // Unbounded backoff: past the stop horizon, leave the alarm unset — a long
145
+ // stable room sweeps no more. A join (ensureSweep) or eviction revives it.
146
+ if (next <= SWEEP_STOP_MS) {
147
+ await this.state.storage.setAlarm(now + next);
123
148
  }
124
149
  }
125
150
 
@@ -151,11 +176,12 @@ export class Room implements DurableObject {
151
176
  return att;
152
177
  }
153
178
 
154
- /** Arm the sweep alarm if one isn't already pending. */
179
+ /** (Re)arm the sweep at the floor cadence. Called when a peer joins: a roster
180
+ * change should be re-checked promptly, so reset the backoff and pull the
181
+ * alarm in even if a (backed-off) one is already pending. */
155
182
  private async ensureSweep(): Promise<void> {
156
- if ((await this.state.storage.getAlarm()) == null) {
157
- await this.state.storage.setAlarm(Date.now() + SWEEP_MS);
158
- }
183
+ await this.state.storage.put(SWEEP_KEY, SWEEP_MIN_MS);
184
+ await this.state.storage.setAlarm(Date.now() + SWEEP_MIN_MS);
159
185
  }
160
186
 
161
187
  private findByPeerId(peerId: string): WebSocket | null {