syntaur 0.17.0 → 0.17.1

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.
@@ -4270,13 +4270,16 @@ __export(scanner_exports, {
4270
4270
  autoLinkPane: () => autoLinkPane,
4271
4271
  checkTmuxAvailable: () => checkTmuxAvailable,
4272
4272
  clearScanCache: () => clearScanCache,
4273
+ descendantPidsFromSnapshot: () => descendantPidsFromSnapshot,
4273
4274
  execQuiet: () => execQuiet,
4274
4275
  findListeningPorts: () => findListeningPorts,
4275
4276
  getDescendantPids: () => getDescendantPids,
4276
4277
  getGitInfo: () => getGitInfo,
4277
4278
  getLsofOutput: () => getLsofOutput,
4279
+ getProcessSnapshot: () => getProcessSnapshot,
4278
4280
  listTmuxPanes: () => listTmuxPanes,
4279
4281
  loadWorkspaceRecords: () => loadWorkspaceRecords,
4282
+ parseProcessSnapshot: () => parseProcessSnapshot,
4280
4283
  parseTmuxPaneOutput: () => parseTmuxPaneOutput,
4281
4284
  resolveAndNormalize: () => resolveAndNormalize,
4282
4285
  scanAllSessions: () => scanAllSessions,
@@ -4289,6 +4292,23 @@ import { resolve as resolve12 } from "path";
4289
4292
  import { realpath, readdir as readdir7, readFile as readFile9 } from "fs/promises";
4290
4293
  function clearScanCache() {
4291
4294
  cache = null;
4295
+ forceFreshNext = true;
4296
+ scanEpoch++;
4297
+ inFlight = null;
4298
+ }
4299
+ function emptyScan() {
4300
+ return { sessions: [], tmuxAvailable: false };
4301
+ }
4302
+ function scanKey(serversDir2, projectsDir, assignmentsDir2) {
4303
+ return `${serversDir2}\0${projectsDir}\0${assignmentsDir2 ?? ""}`;
4304
+ }
4305
+ function delay(ms) {
4306
+ return new Promise((resolve29) => {
4307
+ const timer2 = setTimeout(resolve29, ms);
4308
+ if (typeof timer2.unref === "function") {
4309
+ timer2.unref();
4310
+ }
4311
+ });
4292
4312
  }
4293
4313
  function parseTmuxPaneOutput(output) {
4294
4314
  return output.trim().split("\n").filter((line) => line.length > 0).map((line) => {
@@ -4322,7 +4342,10 @@ function findListeningPorts(lsofOutput, pids) {
4322
4342
  }
4323
4343
  async function execQuiet(cmd, args) {
4324
4344
  try {
4325
- const { stdout } = await exec(cmd, args);
4345
+ const { stdout } = await exec(cmd, args, {
4346
+ timeout: PROBE_TIMEOUT_MS,
4347
+ maxBuffer: PROBE_MAX_BUFFER
4348
+ });
4326
4349
  return stdout.trim();
4327
4350
  } catch {
4328
4351
  return "";
@@ -4370,6 +4393,40 @@ async function getDescendantPids(rootPid, maxDepth = 4) {
4370
4393
  }
4371
4394
  return all;
4372
4395
  }
4396
+ function parseProcessSnapshot(psOutput) {
4397
+ const childrenOf = /* @__PURE__ */ new Map();
4398
+ for (const line of psOutput.split("\n")) {
4399
+ const parts = line.trim().split(/\s+/);
4400
+ if (parts.length < 2) continue;
4401
+ const pid = parseInt(parts[0], 10);
4402
+ const ppid = parseInt(parts[1], 10);
4403
+ if (isNaN(pid) || isNaN(ppid)) continue;
4404
+ const siblings = childrenOf.get(ppid);
4405
+ if (siblings) siblings.push(pid);
4406
+ else childrenOf.set(ppid, [pid]);
4407
+ }
4408
+ return { childrenOf };
4409
+ }
4410
+ async function getProcessSnapshot() {
4411
+ return parseProcessSnapshot(await execQuiet("ps", ["-axo", "pid=,ppid="]));
4412
+ }
4413
+ function descendantPidsFromSnapshot(rootPid, snapshot, maxDepth = 4) {
4414
+ const all = /* @__PURE__ */ new Set([rootPid]);
4415
+ let frontier = [rootPid];
4416
+ for (let depth = 0; depth < maxDepth && frontier.length > 0; depth++) {
4417
+ const nextFrontier = [];
4418
+ for (const pid of frontier) {
4419
+ for (const child of snapshot.childrenOf.get(pid) ?? []) {
4420
+ if (!all.has(child)) {
4421
+ all.add(child);
4422
+ nextFrontier.push(child);
4423
+ }
4424
+ }
4425
+ }
4426
+ frontier = nextFrontier;
4427
+ }
4428
+ return all;
4429
+ }
4373
4430
  async function getLsofOutput() {
4374
4431
  return execQuiet("lsof", ["-i", "-P", "-n", "-sTCP:LISTEN"]);
4375
4432
  }
@@ -4475,7 +4532,7 @@ async function autoLinkPane(cwd, branch, records) {
4475
4532
  }
4476
4533
  return null;
4477
4534
  }
4478
- async function scanSession(sessionData, lsofOutput, workspaceRecords) {
4535
+ async function scanSession(sessionData, lsofOutput, workspaceRecords, procSnapshot) {
4479
4536
  const now = (/* @__PURE__ */ new Date()).toISOString();
4480
4537
  const alive = await sessionAlive(sessionData.session);
4481
4538
  if (!alive) {
@@ -4498,13 +4555,13 @@ async function scanSession(sessionData, lsofOutput, workspaceRecords) {
4498
4555
  windowMap.get(rp.windowIndex).panes.push(rp);
4499
4556
  }
4500
4557
  const cwdSet = new Set(rawPanes.map((p) => p.cwd));
4501
- const gitInfoCache = /* @__PURE__ */ new Map();
4502
- for (const cwd of cwdSet) {
4503
- gitInfoCache.set(cwd, await getGitInfo(cwd));
4504
- }
4558
+ const gitInfoEntries = await Promise.all(
4559
+ [...cwdSet].map(async (cwd) => [cwd, await getGitInfo(cwd)])
4560
+ );
4561
+ const gitInfoCache = new Map(gitInfoEntries);
4505
4562
  const pidToPaneKey = /* @__PURE__ */ new Map();
4506
4563
  for (const rp of rawPanes) {
4507
- const descendants = await getDescendantPids(rp.pid);
4564
+ const descendants = descendantPidsFromSnapshot(rp.pid, procSnapshot);
4508
4565
  const key = `${rp.windowIndex}:${rp.paneIndex}`;
4509
4566
  for (const pid of descendants) {
4510
4567
  pidToPaneKey.set(pid, key);
@@ -4529,37 +4586,38 @@ async function scanSession(sessionData, lsofOutput, workspaceRecords) {
4529
4586
  }
4530
4587
  const windows = [];
4531
4588
  for (const [windowIndex, { name, panes: rawPanesInWindow }] of windowMap) {
4532
- const panes = [];
4533
- for (const rp of rawPanesInWindow) {
4534
- const key = `${rp.windowIndex}:${rp.paneIndex}`;
4535
- const gitInfo = gitInfoCache.get(rp.cwd) ?? { branch: null, worktree: false };
4536
- const ports = panePorts.get(key) ?? [];
4537
- const urls = ports.map((p) => `http://localhost:${p}`);
4538
- const override = sessionData.overrides[key];
4539
- let assignment = null;
4540
- if (override) {
4541
- const rec = workspaceRecords.find(
4542
- (r) => r.projectSlug === override.project && r.assignmentSlug === override.assignment
4543
- );
4544
- assignment = {
4545
- project: override.project,
4546
- slug: override.assignment,
4547
- title: rec?.assignmentTitle ?? override.assignment
4589
+ const panes = await Promise.all(
4590
+ rawPanesInWindow.map(async (rp) => {
4591
+ const key = `${rp.windowIndex}:${rp.paneIndex}`;
4592
+ const gitInfo = gitInfoCache.get(rp.cwd) ?? { branch: null, worktree: false };
4593
+ const ports = panePorts.get(key) ?? [];
4594
+ const urls = ports.map((p) => `http://localhost:${p}`);
4595
+ const override = sessionData.overrides[key];
4596
+ let assignment = null;
4597
+ if (override) {
4598
+ const rec = workspaceRecords.find(
4599
+ (r) => r.projectSlug === override.project && r.assignmentSlug === override.assignment
4600
+ );
4601
+ assignment = {
4602
+ project: override.project,
4603
+ slug: override.assignment,
4604
+ title: rec?.assignmentTitle ?? override.assignment
4605
+ };
4606
+ } else {
4607
+ assignment = await autoLinkPane(rp.cwd, gitInfo.branch, workspaceRecords);
4608
+ }
4609
+ return {
4610
+ index: rp.paneIndex,
4611
+ command: rp.command,
4612
+ cwd: rp.cwd,
4613
+ branch: gitInfo.branch,
4614
+ worktree: gitInfo.worktree,
4615
+ ports,
4616
+ urls,
4617
+ assignment
4548
4618
  };
4549
- } else {
4550
- assignment = await autoLinkPane(rp.cwd, gitInfo.branch, workspaceRecords);
4551
- }
4552
- panes.push({
4553
- index: rp.paneIndex,
4554
- command: rp.command,
4555
- cwd: rp.cwd,
4556
- branch: gitInfo.branch,
4557
- worktree: gitInfo.worktree,
4558
- ports,
4559
- urls,
4560
- assignment
4561
- });
4562
- }
4619
+ })
4620
+ );
4563
4621
  windows.push({ index: windowIndex, name, panes });
4564
4622
  }
4565
4623
  windows.sort((a, b) => a.index - b.index);
@@ -4631,39 +4689,92 @@ async function scanProcessSession(sessionData, lsofOutput, workspaceRecords) {
4631
4689
  windows: [{ index: 0, name: "process", panes: [pane] }]
4632
4690
  };
4633
4691
  }
4692
+ async function performScan(serversDir2, projectsDir, options) {
4693
+ const epoch = scanEpoch;
4694
+ const [tmuxAvailable, names, lsofOutput, workspaceRecords, procSnapshot] = await Promise.all([
4695
+ checkTmuxAvailable(),
4696
+ listSessionFiles(serversDir2),
4697
+ getLsofOutput(),
4698
+ loadWorkspaceRecords(projectsDir, options?.assignmentsDir),
4699
+ getProcessSnapshot()
4700
+ ]);
4701
+ const datas = await Promise.all(names.map((name) => readSessionFile(serversDir2, name)));
4702
+ const scanned = await Promise.all(
4703
+ datas.map(async (data) => {
4704
+ if (!data) return null;
4705
+ if (data.kind === "process") {
4706
+ return scanProcessSession(data, lsofOutput, workspaceRecords);
4707
+ }
4708
+ if (tmuxAvailable) {
4709
+ return scanSession(data, lsofOutput, workspaceRecords, procSnapshot);
4710
+ }
4711
+ return null;
4712
+ })
4713
+ );
4714
+ const result = {
4715
+ sessions: scanned.filter((s) => s !== null),
4716
+ tmuxAvailable
4717
+ };
4718
+ if (epoch === scanEpoch) {
4719
+ cache = { data: result, expiry: Date.now() + CACHE_TTL_MS };
4720
+ lastKnown = result;
4721
+ forceFreshNext = false;
4722
+ }
4723
+ return result;
4724
+ }
4725
+ function refreshInBackground(serversDir2, projectsDir, options) {
4726
+ if (!inFlight) {
4727
+ const p = performScan(serversDir2, projectsDir, options);
4728
+ p.finally(() => {
4729
+ if (inFlight === p) inFlight = null;
4730
+ }).catch(() => {
4731
+ });
4732
+ inFlight = p;
4733
+ }
4734
+ return inFlight;
4735
+ }
4634
4736
  async function scanAllSessions(serversDir2, projectsDir, options) {
4737
+ const key = scanKey(serversDir2, projectsDir, options?.assignmentsDir);
4738
+ if (key !== cacheKey) {
4739
+ cache = null;
4740
+ lastKnown = null;
4741
+ inFlight = null;
4742
+ forceFreshNext = false;
4743
+ scanEpoch++;
4744
+ cacheKey = key;
4745
+ }
4635
4746
  if (!options?.bypassCache && cache && Date.now() < cache.expiry) {
4636
4747
  return cache.data;
4637
4748
  }
4638
- const tmuxAvailable = await checkTmuxAvailable();
4639
- const names = await listSessionFiles(serversDir2);
4640
- const lsofOutput = await getLsofOutput();
4641
- const workspaceRecords = await loadWorkspaceRecords(projectsDir, options?.assignmentsDir);
4642
- const sessions = [];
4643
- for (const name of names) {
4644
- const data = await readSessionFile(serversDir2, name);
4645
- if (!data) continue;
4646
- if (data.kind === "process") {
4647
- sessions.push(await scanProcessSession(data, lsofOutput, workspaceRecords));
4648
- } else if (tmuxAvailable) {
4649
- sessions.push(await scanSession(data, lsofOutput, workspaceRecords));
4650
- }
4749
+ const refresh = refreshInBackground(serversDir2, projectsDir, options);
4750
+ const canServeStale = lastKnown !== null && !options?.bypassCache && !forceFreshNext;
4751
+ if (canServeStale) {
4752
+ void refresh.catch(() => {
4753
+ });
4754
+ return lastKnown;
4651
4755
  }
4652
- const result = { sessions, tmuxAvailable };
4653
- cache = { data: result, expiry: Date.now() + CACHE_TTL_MS };
4654
- return result;
4756
+ if (options?.nonBlocking) {
4757
+ return Promise.race([
4758
+ refresh.catch(() => emptyScan()),
4759
+ delay(COLD_WAIT_BUDGET_MS).then(() => emptyScan())
4760
+ ]);
4761
+ }
4762
+ return refresh;
4655
4763
  }
4656
4764
  async function scanSingleSession(serversDir2, projectsDir, name, options) {
4657
4765
  const data = await readSessionFile(serversDir2, name);
4658
4766
  if (!data) return null;
4659
- const lsofOutput = await getLsofOutput();
4660
- const workspaceRecords = await loadWorkspaceRecords(projectsDir, options?.assignmentsDir);
4767
+ const [lsofOutput, workspaceRecords, procSnapshot] = await Promise.all([
4768
+ getLsofOutput(),
4769
+ loadWorkspaceRecords(projectsDir, options?.assignmentsDir),
4770
+ getProcessSnapshot()
4771
+ ]);
4661
4772
  if (data.kind === "process") {
4662
4773
  return scanProcessSession(data, lsofOutput, workspaceRecords);
4663
4774
  }
4664
- return scanSession(data, lsofOutput, workspaceRecords);
4775
+ return scanSession(data, lsofOutput, workspaceRecords, procSnapshot);
4665
4776
  }
4666
- var exec, cache, CACHE_TTL_MS;
4777
+ var exec, cache, lastKnown, inFlight, forceFreshNext, scanEpoch, cacheKey, CACHE_TTL_MS, COLD_WAIT_BUDGET_MS, PROBE_TIMEOUT_MS, PROBE_MAX_BUFFER;
4667
4778
  var init_scanner = __esm({
4668
4779
  "src/dashboard/scanner.ts"() {
4669
4780
  "use strict";
@@ -4672,7 +4783,15 @@ var init_scanner = __esm({
4672
4783
  init_parser();
4673
4784
  exec = promisify(execFile);
4674
4785
  cache = null;
4786
+ lastKnown = null;
4787
+ inFlight = null;
4788
+ forceFreshNext = false;
4789
+ scanEpoch = 0;
4790
+ cacheKey = null;
4675
4791
  CACHE_TTL_MS = 1e4;
4792
+ COLD_WAIT_BUDGET_MS = 2500;
4793
+ PROBE_TIMEOUT_MS = 15e3;
4794
+ PROBE_MAX_BUFFER = 32 * 1024 * 1024;
4676
4795
  }
4677
4796
  });
4678
4797
 
@@ -4927,7 +5046,12 @@ async function getOverview(projectsDir, serversDir2, assignmentsDir2, options =
4927
5046
  const servers = await timed(
4928
5047
  traces,
4929
5048
  "scan-tmux-sessions",
4930
- () => scanAllSessions2(serversDir2, projectsDir, { assignmentsDir: assignmentsDir2 })
5049
+ () => (
5050
+ // Overview only needs aggregate counts — never block its render on a
5051
+ // live scan; serve last-known stats and let the scan refresh in the
5052
+ // background (stale-while-revalidate).
5053
+ scanAllSessions2(serversDir2, projectsDir, { assignmentsDir: assignmentsDir2, nonBlocking: true })
5054
+ )
4931
5055
  );
4932
5056
  if (servers.tmuxAvailable) {
4933
5057
  const alive = servers.sessions.filter((s) => s.alive).length;