windows-development-sandbox 1.18.2 → 1.18.3

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.
Files changed (72) hide show
  1. package/config.example.json +1 -1
  2. package/dist/app/features/defs.d.ts +8 -0
  3. package/dist/app/features/sandbox/sandbox.def.d.ts +12 -0
  4. package/dist/app/features/sandbox/sandbox.def.js +17 -2
  5. package/dist/app/features/sandbox/sandbox.run.d.ts +4 -0
  6. package/dist/app/features/sandbox/sandbox.run.js +4 -0
  7. package/dist/bin/agent-sandbox.js +2 -2
  8. package/dist/bin/{chunk-IIHL7W6B.js → chunk-3G53NNZP.js} +1 -1
  9. package/dist/bin/{chunk-MQIA7GNB.js → chunk-FQWYCF6L.js} +981 -866
  10. package/dist/bin/{chunk-M4OM632I.js → chunk-M6XUQK53.js} +1 -1
  11. package/dist/bin/claude-host.js +2 -2
  12. package/dist/bin/claude-sbx.js +2 -2
  13. package/dist/bin/codex-sbx.js +2 -2
  14. package/dist/bin/gemini-sbx.js +2 -2
  15. package/dist/bin/sandbox.js +2 -2
  16. package/dist/bin/sbx-hide.js +2 -2
  17. package/dist/bin/sbx-show.js +2 -2
  18. package/dist/cli/commands/repair/checks/sandbox.d.ts +3 -1
  19. package/dist/cli/commands/repair/checks/sandbox.js +24 -0
  20. package/dist/core/config/defaults.js +1 -1
  21. package/dist/core/schema/sandbox.js +1 -1
  22. package/dist/core/schema/toolchains.js +2 -2
  23. package/dist/guest/daemon/daemon.js +1 -1
  24. package/dist/guest/daemon/heartbeat.d.ts +6 -0
  25. package/dist/guest/daemon/heartbeat.js +40 -0
  26. package/dist/guest/entries/daemon.js +2 -0
  27. package/dist/guest/governor/core.d.ts +19 -1
  28. package/dist/guest/governor/core.js +23 -1
  29. package/dist/guest/governor/decide.d.ts +14 -0
  30. package/dist/guest/governor/decide.js +52 -7
  31. package/dist/guest/governor/governor.d.ts +4 -1
  32. package/dist/guest/governor/governor.js +9 -2
  33. package/dist/guest/toolchains/native/rust-extras.d.ts +7 -0
  34. package/dist/guest/toolchains/native/rust-extras.js +26 -3
  35. package/dist/guest/toolchains/plan.js +6 -4
  36. package/dist/host/channel/channel.d.ts +1 -1
  37. package/dist/host/channel/channel.js +10 -2
  38. package/dist/host/launch/wsb-file.d.ts +6 -1
  39. package/dist/host/launch/wsb-file.js +9 -5
  40. package/dist/host/life/crash-report.d.ts +2 -0
  41. package/dist/host/life/crash-report.js +4 -0
  42. package/dist/host/life/index.d.ts +1 -0
  43. package/dist/host/life/index.js +1 -0
  44. package/dist/host/life/last-end.d.ts +21 -0
  45. package/dist/host/life/last-end.js +70 -0
  46. package/dist/host/life/watchdog.d.ts +2 -0
  47. package/dist/host/life/watchdog.js +8 -2
  48. package/dist/runtime/agent.js +26 -3
  49. package/dist/runtime/daemon.js +367 -234
  50. package/dist/runtime/db.js +26 -3
  51. package/dist/runtime/main.js +57 -11
  52. package/dist/runtime/netproxy.js +26 -3
  53. package/dist/runtime/tool.js +26 -3
  54. package/dist/runtime/view.js +26 -3
  55. package/dist/selftest/heavy-load.test.js +95 -14
  56. package/dist/selftest/live-terminal.test.js +4 -0
  57. package/dist/shared/channel/run-dir.d.ts +4 -1
  58. package/dist/shared/channel/run-dir.js +7 -1
  59. package/dist/shared/channel/schema.d.ts +23 -0
  60. package/dist/shared/channel/schema.js +21 -0
  61. package/dist/shared/memory/policy.d.ts +10 -0
  62. package/dist/shared/memory/policy.js +17 -1
  63. package/dist/sync/watcher.js +1 -1
  64. package/dist/web/index.html +1 -1
  65. package/dist/web/{main-CWIF4VQI.js → main-6XYO5FZC.js} +43 -43
  66. package/native/csharp/HcsHost/AssemblyInfo.cs +3 -3
  67. package/native/csharp/HostTools/AssemblyInfo.cs +3 -3
  68. package/native/csharp/StorageHelper/AssemblyInfo.cs +3 -3
  69. package/package.json +1 -1
  70. package/sandbox/csharp/MemGov.cs +58 -0
  71. package/schemas/config.schema.json +3 -3
  72. package/schemas/sandbox-config.schema.json +3 -3
@@ -1,7 +1,7 @@
1
1
  import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
2
2
  import {
3
3
  attach
4
- } from "./chunk-MQIA7GNB.js";
4
+ } from "./chunk-FQWYCF6L.js";
5
5
  export {
6
6
  attach
7
7
  };
@@ -2,8 +2,8 @@
2
2
  import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
3
3
  import {
4
4
  run
5
- } from "./chunk-IIHL7W6B.js";
6
- import "./chunk-MQIA7GNB.js";
5
+ } from "./chunk-3G53NNZP.js";
6
+ import "./chunk-FQWYCF6L.js";
7
7
 
8
8
  // src/bin/claude-host.ts
9
9
  var args = process.argv.slice(2);
@@ -2,8 +2,8 @@
2
2
  import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
3
3
  import {
4
4
  run
5
- } from "./chunk-IIHL7W6B.js";
6
- import "./chunk-MQIA7GNB.js";
5
+ } from "./chunk-3G53NNZP.js";
6
+ import "./chunk-FQWYCF6L.js";
7
7
 
8
8
  // src/bin/claude-sbx.ts
9
9
  run(process.argv.slice(2), { harness: "claude" });
@@ -2,8 +2,8 @@
2
2
  import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
3
3
  import {
4
4
  run
5
- } from "./chunk-IIHL7W6B.js";
6
- import "./chunk-MQIA7GNB.js";
5
+ } from "./chunk-3G53NNZP.js";
6
+ import "./chunk-FQWYCF6L.js";
7
7
 
8
8
  // src/bin/codex-sbx.ts
9
9
  run(process.argv.slice(2), { harness: "codex" });
@@ -2,8 +2,8 @@
2
2
  import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
3
3
  import {
4
4
  run
5
- } from "./chunk-IIHL7W6B.js";
6
- import "./chunk-MQIA7GNB.js";
5
+ } from "./chunk-3G53NNZP.js";
6
+ import "./chunk-FQWYCF6L.js";
7
7
 
8
8
  // src/bin/gemini-sbx.ts
9
9
  run(process.argv.slice(2), { harness: "gemini" });
@@ -2,8 +2,8 @@
2
2
  import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
3
3
  import {
4
4
  run
5
- } from "./chunk-IIHL7W6B.js";
6
- import "./chunk-MQIA7GNB.js";
5
+ } from "./chunk-3G53NNZP.js";
6
+ import "./chunk-FQWYCF6L.js";
7
7
 
8
8
  // src/bin/sandbox.ts
9
9
  run(process.argv.slice(2));
@@ -2,8 +2,8 @@
2
2
  import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
3
3
  import {
4
4
  run
5
- } from "./chunk-IIHL7W6B.js";
6
- import "./chunk-MQIA7GNB.js";
5
+ } from "./chunk-3G53NNZP.js";
6
+ import "./chunk-FQWYCF6L.js";
7
7
 
8
8
  // src/bin/sbx-hide.ts
9
9
  run(process.argv.slice(2), { command: "hide" });
@@ -2,8 +2,8 @@
2
2
  import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
3
3
  import {
4
4
  run
5
- } from "./chunk-IIHL7W6B.js";
6
- import "./chunk-MQIA7GNB.js";
5
+ } from "./chunk-3G53NNZP.js";
6
+ import "./chunk-FQWYCF6L.js";
7
7
 
8
8
  // src/bin/sbx-show.ts
9
9
  run(process.argv.slice(2), { command: "show" });
@@ -1,2 +1,4 @@
1
- import type { Check } from '../types.js';
1
+ import type { Check, Finding } from '../types.js';
2
+ /** A warning when the sandbox's paging file takes more than 40 % of the drive (pure); null when it is fine. */
3
+ export declare function pageFileFinding(pageFileMb: number, driveBytes: number): Finding | null;
2
4
  export declare const SANDBOX_CHECKS: Check[];
@@ -3,6 +3,7 @@
3
3
  // on the PC.
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
+ import { diskUsage, humanBytes } from '../../../../core/disk.js';
6
7
  import { dirExists, fileExists, isLink } from '../../../../core/util/index.js';
7
8
  import { SandboxChannel } from '../../../../host/channel/index.js';
8
9
  import { orphanWatchers, runStateProblems, staleLaunchFolders, watcherRecords } from '../../../../host/health/index.js';
@@ -18,6 +19,19 @@ import { deadPidFiles } from '../finders.js';
18
19
  import { fromFacts } from './pc.js';
19
20
  import { ok, plural, rel } from './common.js';
20
21
  const LAUNCH_DAYS = 7;
22
+ /** The paging file may take at most this share of the disk it sits on. */
23
+ const PAGE_FILE_MAX_SHARE = 0.4;
24
+ /** A warning when the sandbox's paging file takes more than 40 % of the drive (pure); null when it is fine. */
25
+ export function pageFileFinding(pageFileMb, driveBytes) {
26
+ if (pageFileMb <= 0 || driveBytes <= 0 || pageFileMb * 1024 * 1024 <= PAGE_FILE_MAX_SHARE * driveBytes)
27
+ return null;
28
+ return {
29
+ status: 'warn',
30
+ detail: 'the paging file is ' + pageFileMb + ' MB on a ' + humanBytes(driveBytes) + ' drive',
31
+ means: 'sandbox.pageFileMb takes more than 40% of the drive, so builds and installs have little room left and the disk fills up (a full disk can freeze the sandbox).',
32
+ fix: 'Run: sandbox config set sandbox.pageFileMb 8192 (or 0 to let Windows decide).',
33
+ };
34
+ }
21
35
  const hiddenWindowsFile = (env) => path.join(env.storageRoot, 'run', 'hidden-windows.json');
22
36
  export const SANDBOX_CHECKS = [
23
37
  {
@@ -33,6 +47,16 @@ export const SANDBOX_CHECKS = [
33
47
  }
34
48
  : ok('off'),
35
49
  },
50
+ {
51
+ id: 'sandbox-pagefile',
52
+ group: 'Sandbox',
53
+ name: 'Paging file size',
54
+ run: (env) => {
55
+ const mb = env.ctx.loaded.config.sandbox.pageFileMb;
56
+ const drive = diskUsage(env.storageRoot).find((d) => d.drive.toUpperCase() === 'C:\\');
57
+ return pageFileFinding(mb, drive?.totalBytes ?? 0) ?? ok(mb > 0 ? mb + ' MB' : "Windows' own");
58
+ },
59
+ },
36
60
  {
37
61
  id: 'sandbox-exe',
38
62
  group: 'Sandbox',
@@ -68,7 +68,7 @@ export const DEFAULTS = {
68
68
  components: [],
69
69
  targets: [],
70
70
  crates: [],
71
- targetDir: 'separate',
71
+ targetDir: 'auto',
72
72
  linker: 'lld',
73
73
  linkSlots: 'auto',
74
74
  },
@@ -21,7 +21,7 @@ export const SandboxSchema = z.strictObject({
21
21
  .describe('(alpha) What runs the sandbox. "windows-sandbox" = Windows Sandbox (the default: desktop, Windows Terminal, GPU, clipboard, audio, printers). "hcs" = a headless Hyper-V isolated Server Core container for agents (no desktop, GPU or clipboard); it needs Windows features and an image on this PC (sandbox engine check hcs lists what is missing), and starting it is not enabled yet in this alpha. sandbox engine shows what each engine can do. Yours only: a project file (.sandbox-config.json) cannot set it.'),
22
22
  memoryMb: z
23
23
  .union([z.literal('auto'), z.number().int().min(2048)])
24
- .describe('Sandbox RAM in MB (>= 2048), or "auto": free RAM minus reserveHostMb, capped at maxMemoryMb. Never more than this PC can keep in RAM for it (its RAM minus the larger of reserveHostMb and 25 %, 2 GB more with vGPU): a bigger value is lowered.'),
24
+ .describe('Sandbox RAM in MB (>= 2048), or "auto": free RAM minus reserveHostMb, capped at maxMemoryMb. Never more than this PC can keep in RAM for it (its RAM minus the larger of reserveHostMb and 25 %, 4 GB more with vGPU): a bigger value is lowered.'),
25
25
  maxMemoryMb: z.number().int().positive().describe('Upper limit for memoryMb "auto".'),
26
26
  maxMemoryPercent: z
27
27
  .number()
@@ -37,13 +37,13 @@ export const ToolchainsSchema = z.strictObject({
37
37
  targetDir: z
38
38
  .string()
39
39
  .min(1)
40
- .describe('"shared": cargo\'s default target\\ (shared with builds on this PC, so builds on either side invalidate the other), "separate": <workspace>\\target\\sandbox (kept, no cross-talk), "local": on the sandbox\'s own disk (fastest, lost every boot), or a guest path.'),
40
+ .describe('"auto" (default): on the sandbox\'s own disk when C: has 25 GB free at boot, else "separate" (builds then write through the mapped workspace, which can overload this PC\'s disk). "shared": cargo\'s default target\\ (shared with builds on this PC, so builds on either side invalidate the other), "separate": <workspace>\\target\\sandbox (kept, no cross-talk), "local": on the sandbox\'s own disk (fastest, lost every boot), or a guest path.'),
41
41
  linker: z
42
42
  .enum(['lld', 'gnu'])
43
43
  .describe('GNU host: "lld" links with Rust\'s own lld through MinGW\'s gcc (much less memory than GNU ld), "gnu" with GNU ld.'),
44
44
  linkSlots: z
45
45
  .union([z.literal('auto'), z.number().int().min(1).max(32)])
46
- .describe('GNU host: how many programs may link at the same time in the whole sandbox (other links wait). "auto" = one per 3 GB of sandbox memory after 6 GB, 2 to 8 (the memory governor slows links down too when memory runs short).'),
46
+ .describe('GNU host: how many programs may link at the same time in the whole sandbox (other links wait). "auto" = one per 6 GB of sandbox memory, 1 to 4 (the memory governor slows links down too when memory runs short).'),
47
47
  }),
48
48
  msvc: z.strictObject({
49
49
  enabled: enabled("MSVC (cl, link) and the Windows SDK, portable from Microsoft's Visual Studio manifest (~1-2 GB once), with vcvars64.bat and vswhere.", 'a .vcxproj, CMakePresets.json, a script calling vcvars/vswhere, or a Rust project when this PC builds Rust with MSVC'),
@@ -63,7 +63,7 @@ export class Daemon {
63
63
  return sessions.close(req);
64
64
  case 'pressure':
65
65
  // No response: the host repeats it every few seconds while its memory stays low.
66
- return governor.hostPressure(req.seconds, req.reason, req.level ?? 'slow');
66
+ return governor.hostPressure(req.seconds, req.reason, req.level ?? 'slow', req.diskQueue);
67
67
  case 'ping':
68
68
  return respond(req.id, { ok: true, message: 'pong (' + sessions.count + ' session(s) running)' });
69
69
  }
@@ -0,0 +1,6 @@
1
+ import type { Heartbeat } from '../../shared/channel/schema.js';
2
+ import { type GovernorSnapshot } from '../governor/index.js';
3
+ /** The heartbeat for these numbers (the governor's snapshot when it has one, else Windows' own free memory). */
4
+ export declare function buildHeartbeat(snapshot: GovernorSnapshot | null, sessionCount: number, uptimeSeconds: number, freeBytes: number, totalBytes: number, at: string): Heartbeat;
5
+ /** Writes the heartbeat now and every 10 seconds; a failed write is only a warning. */
6
+ export declare function startHeartbeat(): void;
@@ -0,0 +1,40 @@
1
+ // The heartbeat: every 10 seconds the daemon writes run\heartbeat.json (memory, the governor's dial, the biggest
2
+ // programs). When the whole sandbox vanishes, the host reads the last one to say what it was doing.
3
+ import os from 'node:os';
4
+ import { writeJsonAtomic } from '../../shared/io/json.js';
5
+ import { now } from '../../shared/channel/run-dir.js';
6
+ import { governor } from '../governor/index.js';
7
+ import { run } from '../runtime/paths.js';
8
+ import { warn } from '../runtime/log.js';
9
+ import { errMsg } from '../../core/util/errors.js';
10
+ import { sessions } from './sessions.js';
11
+ const BEAT_MS = 10000;
12
+ const clamp = (n, low, high) => Math.max(low, Math.min(high, n));
13
+ /** The heartbeat for these numbers (the governor's snapshot when it has one, else Windows' own free memory). */
14
+ export function buildHeartbeat(snapshot, sessionCount, uptimeSeconds, freeBytes, totalBytes, at) {
15
+ const ramFree = snapshot?.ramFreePercent ?? Math.round((freeBytes / Math.max(1, totalBytes)) * 100);
16
+ return {
17
+ at,
18
+ uptimeS: Math.max(0, Math.round(uptimeSeconds)),
19
+ ramFreePct: clamp(ramFree, 0, 100),
20
+ commitPct: clamp(snapshot?.commitPercent ?? 0, 0, 100),
21
+ step: clamp(snapshot?.step ?? 0, 0, 4),
22
+ hostStep: clamp(snapshot?.hostStep ?? 0, 0, 4),
23
+ topProcesses: (snapshot?.top ?? []).slice(0, 5).map((p) => ({ name: p.name.slice(0, 64), privateMb: p.mb })),
24
+ ...(snapshot?.diskQueue != null ? { diskQueueReported: snapshot.diskQueue } : {}),
25
+ phase: sessionCount > 0 ? 'working' : 'ready',
26
+ };
27
+ }
28
+ /** Writes the heartbeat now and every 10 seconds; a failed write is only a warning. */
29
+ export function startHeartbeat() {
30
+ const beat = () => {
31
+ try {
32
+ writeJsonAtomic(run.heartbeatFile, buildHeartbeat(governor.snapshot(), sessions.count, os.uptime(), os.freemem(), os.totalmem(), now()));
33
+ }
34
+ catch (e) {
35
+ warn('heartbeat: ' + errMsg(e));
36
+ }
37
+ };
38
+ beat();
39
+ setInterval(beat, BEAT_MS).unref();
40
+ }
@@ -13,6 +13,7 @@ import { startLiveFolders } from '../daemon/live.js';
13
13
  import { sessions } from '../daemon/sessions.js';
14
14
  import { governor } from '../governor/index.js';
15
15
  import { startAccountWatch } from '../daemon/accounts.js';
16
+ import { startHeartbeat } from '../daemon/heartbeat.js';
16
17
  process.on('uncaughtException', (e) => warn('daemon: unexpected error (continuing): ' + (e.stack ?? errMsg(e))));
17
18
  process.on('unhandledRejection', (e) => warn('daemon: unexpected error (continuing): ' + errMsg(e)));
18
19
  const liveKey = adoptBootSecrets();
@@ -24,4 +25,5 @@ safely('leftover processes', () => sessions.sweepLeftovers());
24
25
  safely('memory governor', () => governor.start());
25
26
  log('Memory governor: ' + governor.describe());
26
27
  safely('service sign-ins', startAccountWatch);
28
+ safely('heartbeat', startHeartbeat);
27
29
  new Daemon().start();
@@ -1,12 +1,26 @@
1
1
  import type { PressureStatus } from '../../shared/channel/schema.js';
2
2
  import type { LoadProfile } from '../../shared/memory/index.js';
3
3
  import { type GovernorState, type MemSample } from './decide.js';
4
+ /** The newest numbers the governor saw (for the heartbeat file). */
5
+ export interface GovernorSnapshot {
6
+ readonly ramFreePercent: number;
7
+ readonly commitPercent: number;
8
+ readonly step: number;
9
+ readonly hostStep: number;
10
+ readonly diskQueue: number | null;
11
+ readonly top: readonly {
12
+ readonly name: string;
13
+ readonly mb: number;
14
+ }[];
15
+ }
4
16
  /** What the governor needs from Windows (injectable: the tests use a fake). */
5
17
  export interface GovernorPort {
6
18
  setPriority(pid: number, to: 'idle' | 'below' | 'normal'): void;
7
19
  /** Suspends for `ms`, then resumes (memgov resumes it even if the daemon goes away). */
8
20
  hold(pid: number, ms: number): void;
9
21
  setMemoryPriority(pid: number, low: boolean): void;
22
+ /** The disk queue priority (very low, low, normal). */
23
+ setIoPriority(pid: number, to: 'verylow' | 'low' | 'normal'): void;
10
24
  /** Into the throttle job (whose CPU cap setCpuRate sets). */
11
25
  throttle(pid: number): void;
12
26
  setCpuRate(percent: number): void;
@@ -35,8 +49,12 @@ export declare class GovernorCore {
35
49
  private lastWriteAt;
36
50
  /** Compilers waiting their turn now, by pid (name), to log a hold once. */
37
51
  private held;
52
+ private latest;
53
+ private diskQueue;
38
54
  constructor(deps: GovernorDeps);
39
- hostPressure(seconds: number, reason: string, level?: 'slow' | 'severe'): void;
55
+ hostPressure(seconds: number, reason: string, level?: 'slow' | 'severe', diskQueue?: number): void;
56
+ /** The newest numbers, null before the first sample. */
57
+ get snapshot(): GovernorSnapshot | null;
40
58
  get current(): GovernorState;
41
59
  onSample(sample: MemSample): void;
42
60
  private apply;
@@ -17,10 +17,14 @@ export class GovernorCore {
17
17
  lastWriteAt = 0;
18
18
  /** Compilers waiting their turn now, by pid (name), to log a hold once. */
19
19
  held = new Map();
20
+ latest = null;
21
+ diskQueue = null;
20
22
  constructor(deps) {
21
23
  this.deps = deps;
22
24
  }
23
- hostPressure(seconds, reason, level = 'slow') {
25
+ hostPressure(seconds, reason, level = 'slow', diskQueue) {
26
+ if (diskQueue !== undefined)
27
+ this.diskQueue = diskQueue;
24
28
  const nowMs = this.deps.now();
25
29
  const until = nowMs + seconds * 1000;
26
30
  const active = this.state.hostUntil > nowMs;
@@ -34,6 +38,10 @@ export class GovernorCore {
34
38
  step);
35
39
  this.state = { ...this.state, hostUntil: Math.max(until, this.state.hostUntil), hostStep: step };
36
40
  }
41
+ /** The newest numbers, null before the first sample. */
42
+ get snapshot() {
43
+ return this.latest ? { ...this.latest, diskQueue: this.diskQueue } : null;
44
+ }
37
45
  get current() {
38
46
  return this.state;
39
47
  }
@@ -42,6 +50,17 @@ export class GovernorCore {
42
50
  const before = this.state.step;
43
51
  const d = decide(this.state, sample, nowMs, this.deps.profile, this.deps.maxCompilers ?? null);
44
52
  this.state = d.state;
53
+ this.latest = {
54
+ ramFreePercent: d.ramFreePercent,
55
+ commitPercent: d.commitPercent,
56
+ step: d.state.step,
57
+ hostStep: nowMs < d.state.hostUntil ? d.state.hostStep : 0,
58
+ diskQueue: null,
59
+ top: [...sample.procs]
60
+ .sort((a, b) => b.mb - a.mb)
61
+ .slice(0, 5)
62
+ .map((p) => ({ name: p.name.slice(0, 64), mb: Math.round(p.mb) })),
63
+ };
45
64
  const holding = new Set();
46
65
  for (const action of d.actions) {
47
66
  if (action.kind === 'hold')
@@ -98,6 +117,9 @@ export class GovernorCore {
98
117
  port.setPriority(action.pid, action.to);
99
118
  this.note(action.name + ' (' + action.pid + ') priority ' + action.to);
100
119
  return;
120
+ case 'io':
121
+ port.setIoPriority(action.pid, action.to);
122
+ return;
101
123
  case 'memory':
102
124
  port.setMemoryPriority(action.pid, action.low);
103
125
  return;
@@ -1,5 +1,7 @@
1
1
  import { type LoadProfile } from '../../shared/memory/index.js';
2
2
  export type Priority = 'idle' | 'below' | 'normal';
3
+ /** I/O priority of a process (NtSetInformationProcess ProcessIoPriority): its disk requests queue behind the rest. */
4
+ export type IoPriority = 'verylow' | 'low' | 'normal';
3
5
  export interface ProcSample {
4
6
  readonly pid: number;
5
7
  readonly name: string;
@@ -17,6 +19,9 @@ export interface MemSample {
17
19
  }
18
20
  export declare const isNeverTouched: (name: string) => boolean;
19
21
  export declare const canLower: (name: string) => boolean;
22
+ export declare const isLinker: (name: string) => boolean;
23
+ /** Links that may run at once ("auto"): one per 6 GB of RAM, 1 to 4 (never more than the processors). */
24
+ export declare function linkerSlots(totalMb: number, cpus: number): number;
20
25
  export declare const isHeavyCompiler: (name: string) => boolean;
21
26
  export declare const canPause: (name: string) => boolean;
22
27
  export type Level = 'ok' | 'throttled' | 'suspending';
@@ -33,6 +38,8 @@ export interface GovernorState {
33
38
  readonly throttled: readonly number[];
34
39
  /** Pids whose working set was trimmed while they waited. */
35
40
  readonly trimmed: readonly number[];
41
+ /** Pids whose I/O priority was lowered, and to what. */
42
+ readonly ioLowered: Readonly<Record<number, IoPriority>>;
36
43
  /** The CPU cap last set on the throttle job (percent; 100 = none). */
37
44
  readonly cpuRate: number;
38
45
  /** Used commit (MB) and when, at the last sample: how fast it grows. */
@@ -51,6 +58,13 @@ export type Action = {
51
58
  readonly pid: number;
52
59
  readonly name: string;
53
60
  readonly to: Priority;
61
+ }
62
+ /** Disk requests of a compiler or linker queue behind everything else (the PC's disks are the scarce thing). */
63
+ | {
64
+ readonly kind: 'io';
65
+ readonly pid: number;
66
+ readonly name: string;
67
+ readonly to: IoPriority;
54
68
  } | {
55
69
  readonly kind: 'memory';
56
70
  readonly pid: number;
@@ -100,6 +100,13 @@ const HEAVY = new Set([
100
100
  'ld',
101
101
  'ld.lld',
102
102
  ]);
103
+ /** Linkers: the biggest memory and disk users of a build, so they run one at a time from step 2. */
104
+ const LINKERS = new Set(['link', 'lld', 'lld-link', 'ld', 'ld.lld']);
105
+ export const isLinker = (name) => !isNeverTouched(name) && LINKERS.has(key(name));
106
+ /** Links that may run at once ("auto"): one per 6 GB of RAM, 1 to 4 (never more than the processors). */
107
+ export function linkerSlots(totalMb, cpus) {
108
+ return Math.max(1, Math.min(4, Math.max(1, Math.floor(cpus)), Math.floor(totalMb / 6144)));
109
+ }
103
110
  export const isHeavyCompiler = (name) => !isNeverTouched(name) && HEAVY.has(key(name));
104
111
  export const canPause = (name) => !isNeverTouched(name) && COMPILERS.has(key(name));
105
112
  export const INITIAL_STATE = {
@@ -109,6 +116,7 @@ export const INITIAL_STATE = {
109
116
  memLow: [],
110
117
  throttled: [],
111
118
  trimmed: [],
119
+ ioLowered: {},
112
120
  cpuRate: 100,
113
121
  lastCommitMb: 0,
114
122
  lastAt: 0,
@@ -172,6 +180,10 @@ export function decide(prev, sample, nowMs, profile, maxCompilers = null) {
172
180
  calm = 0;
173
181
  }
174
182
  }
183
+ // vGPU on (the gentle profile): a GPU game and a build together have killed the whole VM, so while any heavy compiler
184
+ // or linker runs the dial is at step 2 at least. The game itself is never touched (only build programs are).
185
+ if (profile === 'gentle' && step < 2 && sample.procs.some((p) => isHeavyCompiler(p.name)))
186
+ step = 2;
175
187
  const actions = [];
176
188
  const present = new Set(sample.procs.map((p) => p.pid));
177
189
  const keep = (pids) => pids.filter((pid) => present.has(pid));
@@ -198,6 +210,25 @@ export function decide(prev, sample, nowMs, profile, maxCompilers = null) {
198
210
  else
199
211
  lowered[p.pid] = target;
200
212
  }
213
+ // I/O priority of compilers and linkers: low while their CPU priority is below normal, very low from step 3 (a PC
214
+ // whose disks are overloaded asks for step 4). Restored to normal when they are released.
215
+ const ioLowered = {};
216
+ for (const [pid, to] of Object.entries(prev.ioLowered))
217
+ if (present.has(Number(pid)))
218
+ ioLowered[Number(pid)] = to;
219
+ for (const p of sample.procs) {
220
+ if (!canPause(p.name))
221
+ continue;
222
+ const cpu = lowered[p.pid] ?? 'normal';
223
+ const target = step >= 3 ? 'verylow' : cpu !== 'normal' ? 'low' : 'normal';
224
+ const was = ioLowered[p.pid] ?? 'normal';
225
+ if (was !== target)
226
+ actions.push({ kind: 'io', pid: p.pid, name: p.name, to: target });
227
+ if (target === 'normal')
228
+ delete ioLowered[p.pid];
229
+ else
230
+ ioLowered[p.pid] = target;
231
+ }
201
232
  // Memory priority: low from step 1, so Windows trims the builds' pages before the agents'.
202
233
  const lowMemory = step >= 1;
203
234
  for (const p of sample.procs) {
@@ -224,20 +255,33 @@ export function decide(prev, sample, nowMs, profile, maxCompilers = null) {
224
255
  let allowed = compilersAllowed(step, heavy.length);
225
256
  // The cap is a memory measure: with memory to spare, Windows shares the CPU (the priorities above) and no compiler
226
257
  // waits. A waiting rustc also holds cargo's job tokens, which stalls the whole build.
227
- if (maxCompilers !== null && step >= 2)
258
+ if (maxCompilers !== null && step >= 1)
228
259
  allowed = Math.min(allowed, Math.max(1, maxCompilers));
229
- if (allowed < heavy.length) {
230
- const turn = Math.floor(nowMs / ROTATE_MS) % heavy.length;
231
- const order = [...heavy.slice(turn), ...heavy.slice(0, turn)];
232
- for (const p of order.slice(allowed)) {
260
+ const turn = heavy.length > 0 ? Math.floor(nowMs / ROTATE_MS) % heavy.length : 0;
261
+ const order = [...heavy.slice(turn), ...heavy.slice(0, turn)];
262
+ let waiting = order.slice(Math.min(allowed, order.length));
263
+ // From step 2 the linkers that would run go one at a time, whatever the cap: a link reads and writes the most.
264
+ if (step >= 2) {
265
+ const waitingPids = new Set(waiting.map((p) => p.pid));
266
+ let linking = 0;
267
+ for (const p of order)
268
+ if (isLinker(p.name) && !waitingPids.has(p.pid) && ++linking > 1) {
269
+ waiting = [...waiting, p];
270
+ waitingPids.add(p.pid);
271
+ }
272
+ }
273
+ if (waiting.length > 0) {
274
+ const waitingPids = new Set(waiting.map((p) => p.pid));
275
+ for (const p of waiting) {
233
276
  actions.push({ kind: 'hold', pid: p.pid, name: p.name, ms: HOLD_MS });
234
277
  if (step >= 3 && !trimmed.has(p.pid)) {
235
278
  actions.push({ kind: 'trim', pid: p.pid, name: p.name });
236
279
  trimmed.add(p.pid);
237
280
  }
238
281
  }
239
- for (const p of order.slice(0, allowed))
240
- trimmed.delete(p.pid);
282
+ for (const p of order)
283
+ if (!waitingPids.has(p.pid))
284
+ trimmed.delete(p.pid);
241
285
  }
242
286
  else
243
287
  trimmed.clear();
@@ -250,6 +294,7 @@ export function decide(prev, sample, nowMs, profile, maxCompilers = null) {
250
294
  memLow: [...memLow],
251
295
  throttled: [...throttled],
252
296
  trimmed: [...trimmed],
297
+ ioLowered,
253
298
  cpuRate,
254
299
  lastCommitMb: usedMb,
255
300
  lastAt: nowMs,
@@ -1,9 +1,12 @@
1
+ import { type GovernorSnapshot } from './core.js';
1
2
  /** The daemon's governor: on unless sandbox.pressureResponse is off. */
2
3
  export declare class Governor {
3
4
  private core;
4
5
  describe(): string;
5
6
  start(): void;
6
7
  /** The host's watchdog: this PC is low (or nearly out of memory) for a while. */
7
- hostPressure(seconds: number, reason: string, level?: 'slow' | 'severe'): void;
8
+ hostPressure(seconds: number, reason: string, level?: 'slow' | 'severe', diskQueue?: number): void;
9
+ /** The newest numbers, for the heartbeat (null when the governor is off or has not sampled yet). */
10
+ snapshot(): GovernorSnapshot | null;
8
11
  }
9
12
  export declare const governor: Governor;
@@ -72,6 +72,9 @@ class MemGovProcess {
72
72
  setMemoryPriority(pid, low) {
73
73
  this.send('memprio ' + pid + ' ' + (low ? 'low' : 'normal'));
74
74
  }
75
+ setIoPriority(pid, to) {
76
+ this.send('iopriority ' + pid + ' ' + to);
77
+ }
75
78
  throttle(pid) {
76
79
  this.send('throttle ' + pid);
77
80
  }
@@ -143,8 +146,12 @@ export class Governor {
143
146
  proc.start();
144
147
  }
145
148
  /** The host's watchdog: this PC is low (or nearly out of memory) for a while. */
146
- hostPressure(seconds, reason, level = 'slow') {
147
- this.core?.hostPressure(seconds, reason, level);
149
+ hostPressure(seconds, reason, level = 'slow', diskQueue) {
150
+ this.core?.hostPressure(seconds, reason, level, diskQueue);
151
+ }
152
+ /** The newest numbers, for the heartbeat (null when the governor is off or has not sampled yet). */
153
+ snapshot() {
154
+ return this.core?.snapshot ?? null;
148
155
  }
149
156
  }
150
157
  export const governor = new Governor();
@@ -1,5 +1,12 @@
1
1
  import type { Toolchains } from '../../../core/types.js';
2
2
  import { type ToolEnv } from '../tool-env.js';
3
+ /**
4
+ * "auto" (the default): build on the sandbox's own disk when C: has 25 GB free, else keep the target next to the
5
+ * project, which writes every object file through the mapped workspace to this PC's disk. Other values stay as set.
6
+ */
7
+ export declare function resolveTargetDir(rust: Toolchains['rust'], free?: number, note?: (text: string) => void): Toolchains['rust'];
8
+ /** One summary line for the agent: where cargo builds. */
9
+ export declare function targetSummary(rust: Toolchains['rust']): string;
3
10
  /** "separate": tools\shims\cargo\cargo.exe in front of the real cargo, which it runs with the checkout's target dir. */
4
11
  export declare function cargoShim(env: ToolEnv, rust: Toolchains['rust']): void;
5
12
  /**
@@ -7,7 +7,31 @@ import { execOutput } from '../../runtime/process.js';
7
7
  import { ensureCompiled } from '../../runtime/csc.js';
8
8
  import { arch, tool } from '../tool-env.js';
9
9
  import { wanted } from '../detect.js';
10
- import { cargoJobs, linkSlotsFor } from '../../../shared/memory/index.js';
10
+ import { cargoJobs, chooseTargetDir } from '../../../shared/memory/index.js';
11
+ import { linkerSlots } from '../../governor/decide.js';
12
+ import { warn } from '../../runtime/log.js';
13
+ import { freeBytes } from '../persisted.js';
14
+ /**
15
+ * "auto" (the default): build on the sandbox's own disk when C: has 25 GB free, else keep the target next to the
16
+ * project, which writes every object file through the mapped workspace to this PC's disk. Other values stay as set.
17
+ */
18
+ export function resolveTargetDir(rust, free = freeBytes((process.env.SystemDrive ?? 'C:') + '\\'), note = warn) {
19
+ if (rust.targetDir !== 'auto')
20
+ return rust;
21
+ const chosen = chooseTargetDir(rust.targetDir, free);
22
+ if (chosen.warning)
23
+ note(chosen.warning);
24
+ return { ...rust, targetDir: chosen.targetDir };
25
+ }
26
+ /** One summary line for the agent: where cargo builds. */
27
+ export function targetSummary(rust) {
28
+ const target = cargoTarget(rust.targetDir);
29
+ if (target)
30
+ return 'rust builds: ' + target + ' (CARGO_TARGET_DIR)';
31
+ return rust.targetDir === 'shared'
32
+ ? 'rust builds: target\\ in each project (shared with this PC)'
33
+ : 'rust builds: target\\sandbox in each project (on the mapped workspace)';
34
+ }
11
35
  /**
12
36
  * Where cargo builds: shared with the PC's target\, one on the sandbox's disk, or a path. "separate" sets no variable:
13
37
  * the cargo shim (sandbox/csharp/CargoShim.cs) gives every checkout its own target\sandbox, so git worktrees of several
@@ -40,8 +64,7 @@ export function linkGate(env, rust, host) {
40
64
  return;
41
65
  const gate = path.join(mkdirp(tool('shims')), 'linkgate.exe');
42
66
  fs.copyFileSync(ensureCompiled('linkgate'), gate);
43
- const memoryGb = os.totalmem() / 1024 ** 3;
44
- const slots = rust.linkSlots === 'auto' ? linkSlotsFor(memoryGb) : rust.linkSlots;
67
+ const slots = rust.linkSlots === 'auto' ? linkerSlots(os.totalmem() / 1024 ** 2, os.cpus().length) : rust.linkSlots;
45
68
  env.vars.AGENT_LINK_SLOTS = String(slots);
46
69
  env.vars.AGENT_REAL_LINKER = gcc;
47
70
  env.vars.CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER = gate;
@@ -32,7 +32,7 @@ import { setupFlutter } from './mobile/flutter.js';
32
32
  import { setupDart } from './mobile/dart.js';
33
33
  import { setupReactNative } from './mobile/react-native.js';
34
34
  import { setupKotlin } from './languages/kotlin.js';
35
- import { rustHost, rustCaches, cargoDefaults, cargoShim, linkGate } from './native/rust-extras.js';
35
+ import { rustHost, rustCaches, cargoDefaults, cargoShim, linkGate, resolveTargetDir, targetSummary, } from './native/rust-extras.js';
36
36
  import { setupPythonTools } from './languages/python-tools.js';
37
37
  export function plan(config, versions, o) {
38
38
  const apps = Boolean(o.apps && (o.apps.install.length || Object.keys(o.apps.custom).length));
@@ -92,11 +92,13 @@ export function plan(config, versions, o) {
92
92
  after: ['C/C++', 'MSVC', 'Visual Studio', 'Unreal'],
93
93
  install: async (env) => {
94
94
  await setupRust(env, { ...config.rust, host: rustHost(config) }, o.refresh);
95
- Object.assign(env.vars, rustCaches(config.rust));
95
+ const rust = resolveTargetDir(config.rust);
96
+ Object.assign(env.vars, rustCaches(rust));
97
+ env.summary.push(targetSummary(rust));
96
98
  if (!dryRun) {
97
99
  cargoDefaults(rustHost(config));
98
- cargoShim(env, config.rust);
99
- linkGate(env, config.rust, rustHost(config));
100
+ cargoShim(env, rust);
101
+ linkGate(env, rust, rustHost(config));
100
102
  }
101
103
  },
102
104
  },
@@ -51,7 +51,7 @@ export declare class SandboxChannel {
51
51
  /** Ends a running session (agent or shell) and everything it started; waits for the sandbox's answer. */
52
52
  closeSession(sessionId: string): Promise<GuestResponse>;
53
53
  /** The PC is low on memory or CPU: the sandbox slows its builds down for `seconds`. Never waits for an answer. */
54
- pressure(reason: string, seconds: number, level?: 'slow' | 'severe'): void;
54
+ pressure(reason: string, seconds: number, level?: 'slow' | 'severe', diskQueue?: number): void;
55
55
  ping(): Promise<GuestResponse>;
56
56
  runningSessions(): SessionStatus[];
57
57
  /** Running sessions of the sandbox that is running now (its launch id), newest last, capped. */