@venturewild/workspace 0.6.26 → 0.6.27

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@venturewild/workspace",
3
- "version": "0.6.26",
3
+ "version": "0.6.27",
4
4
  "description": "Claude Code Web — Replit/Lovable-style chat-first browser UI that wraps the AI agent already installed on your machine.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -26,12 +26,38 @@ import {
26
26
  writeFileSync,
27
27
  unlinkSync,
28
28
  } from 'node:fs';
29
+ import net from 'node:net';
29
30
  import path from 'node:path';
30
31
  import os from 'node:os';
31
32
  import { resolveDaemonBinary } from './daemon-bin.mjs';
32
33
 
33
34
  const DEFAULT_HTTP_BASE = 'http://127.0.0.1:8320';
34
35
 
36
+ /**
37
+ * Is something LISTENING on host:port? A successful TCP connect means the socket
38
+ * is still held (a new daemon would hit EADDRINUSE); ECONNREFUSED means nothing
39
+ * is listening — the port is FREE to bind. Loopback connects resolve instantly,
40
+ * so this is fast. Conservative on ambiguous outcomes (timeout / odd error):
41
+ * report "in use" rather than risk spawning into a half-released socket.
42
+ * @returns {Promise<boolean>} true = in use, false = free to bind.
43
+ */
44
+ function defaultProbePort(host, port, timeoutMs = 1000) {
45
+ return new Promise((resolve) => {
46
+ const sock = net.connect({ host, port });
47
+ let done = false;
48
+ const finish = (inUse) => {
49
+ if (done) return;
50
+ done = true;
51
+ try { sock.destroy(); } catch { /* already closed */ }
52
+ resolve(inUse);
53
+ };
54
+ sock.setTimeout(timeoutMs);
55
+ sock.once('connect', () => finish(true)); // someone is listening
56
+ sock.once('timeout', () => finish(true)); // filtered/hung — assume held
57
+ sock.once('error', (e) => finish(e?.code !== 'ECONNREFUSED')); // refused = free
58
+ });
59
+ }
60
+
35
61
  export class DaemonSupervisor {
36
62
  /**
37
63
  * @param {object} [opts]
@@ -53,6 +79,7 @@ export class DaemonSupervisor {
53
79
  spawnImpl = spawn,
54
80
  fetchImpl = globalThis.fetch,
55
81
  killImpl = (pid, sig) => process.kill(pid, sig),
82
+ probePortImpl = defaultProbePort, // (host, port, timeoutMs) -> Promise<bool>; test seam
56
83
  env = process.env,
57
84
  // b-ii: when the install is logged in (account.json present), these are
58
85
  // injected into the daemon's spawn env so it opens the proxy link
@@ -72,6 +99,7 @@ export class DaemonSupervisor {
72
99
  this.spawnImpl = spawnImpl;
73
100
  this.fetchImpl = fetchImpl;
74
101
  this.killImpl = killImpl;
102
+ this.probePortImpl = probePortImpl;
75
103
  this.env = env;
76
104
  this.accountToken = accountToken;
77
105
  this.serverUrl = serverUrl;
@@ -246,18 +274,52 @@ export class DaemonSupervisor {
246
274
  return { stopped: true, pid };
247
275
  }
248
276
 
277
+ /** The daemon's API host+port, parsed from httpBase (for raw socket checks). */
278
+ get apiHostPort() {
279
+ try {
280
+ const u = new URL(this.httpBase);
281
+ return { host: u.hostname || '127.0.0.1', port: Number(u.port) || 8320 };
282
+ } catch {
283
+ return { host: '127.0.0.1', port: 8320 };
284
+ }
285
+ }
286
+
287
+ /** Is the daemon's API port still held by a listener? (true = can't bind yet) */
288
+ portInUse(timeoutMs = 1000) {
289
+ const { host, port } = this.apiHostPort;
290
+ return this.probePortImpl(host, port, timeoutMs);
291
+ }
292
+
293
+ /**
294
+ * Block until the daemon's API port is FREE to bind, or the deadline passes.
295
+ * Returns true if it became free, false on timeout (caller proceeds anyway —
296
+ * best-effort, like the old health-poll).
297
+ */
298
+ async waitForPortFree(timeoutMs = 8000, pollMs = 150) {
299
+ const deadline = Date.now() + timeoutMs;
300
+ for (;;) {
301
+ if (!(await this.portInUse())) return true;
302
+ if (Date.now() >= deadline) return false;
303
+ await new Promise((r) => setTimeout(r, pollMs));
304
+ }
305
+ }
306
+
249
307
  /**
250
308
  * Recycle the daemon so it loads a freshly-resolved binary (after an
251
- * auto-update). Stop the running process, wait for it to release its API port,
252
- * then spawn again. Returns the `spawnDaemon` result.
309
+ * auto-update). Stop the running process, wait for it to release its API
310
+ * SOCKET, then spawn again. Returns the `spawnDaemon` result.
311
+ *
312
+ * Why wait on the socket, not health(): health() going false only means the
313
+ * daemon stopped answering HTTP — the OS can hold the :8320 listening socket a
314
+ * beat longer (graceful-shutdown drain / close). The daemon does NOT retry its
315
+ * bind: it logs "Address already in use" once and gives up, so the b-ii proxy
316
+ * link never forms and the public URL stays 502 until a reboot
317
+ * (docs/onboarding-hardening-2026-06-23.md). Spawning only once the socket is
318
+ * actually free closes that race.
253
319
  */
254
320
  async recycle() {
255
321
  await this.stop();
256
- // Wait for the old process to exit + free :PORT, else the new one can't bind.
257
- const deadline = Date.now() + 5000;
258
- while (Date.now() < deadline && (await this.health()).running) {
259
- await new Promise((r) => setTimeout(r, 200));
260
- }
322
+ await this.waitForPortFree();
261
323
  return this.spawnDaemon();
262
324
  }
263
325