windows-development-sandbox 1.11.4 → 1.11.5

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.
@@ -209,7 +209,6 @@ export function agentSection(a) {
209
209
  id: a.name,
210
210
  title: a.name,
211
211
  summary: a.accounts.length ? plural(a.accounts.length, 'saved login') : 'no saved login yet',
212
- description: 'Saved ' + a.name + ' logins.',
213
212
  cards: true,
214
213
  items: [...saveItems(a), ...a.accounts.map((r) => loginItem(a, r))],
215
214
  };
@@ -165,7 +165,10 @@ export const enableSandbox = defineOperation({
165
165
  },
166
166
  },
167
167
  });
168
- /** A repair row: what it means is the description; what was found and the fix are the detail lines. */
168
+ /**
169
+ * A repair row: what it means is the description; what was found and the fix are label/value detail lines ("Found:",
170
+ * "Fix:"). An ok row has nothing to fix, so what it found is its description.
171
+ */
169
172
  function checkItems(last) {
170
173
  return last.checks.map((r, i) => {
171
174
  const found = r.detail + (r.timedOut ? ' (took too long)' : '');
@@ -173,14 +176,14 @@ function checkItems(last) {
173
176
  .slice(1)
174
177
  .map((l) => l.text.trim())
175
178
  .filter((l) => !l.startsWith('What it means: '));
176
- const details = r.means ? [found, ...rest] : rest;
179
+ const details = r.status === 'ok' ? [] : ['Found: ' + found, ...rest];
177
180
  return {
178
181
  id: 'repair:' + i,
179
182
  label: r.name,
180
183
  hint: BADGE[r.status],
181
184
  ...(TONE[r.status] ? { tone: TONE[r.status] } : {}),
182
185
  badges: [{ text: BADGE[r.status], ...(TONE[r.status] ? { tone: TONE[r.status] } : {}) }],
183
- description: r.means ?? found,
186
+ description: r.means ?? (details.length ? undefined : found),
184
187
  ...(details.length ? { details } : {}),
185
188
  };
186
189
  });
@@ -130,12 +130,12 @@ function projectItem(p) {
130
130
  hint: p.host + ' -> ' + p.guest + ' (' + (MECHANISM_TEXT[p.mechanism] ?? 'wsb share') + ')',
131
131
  marked: p.here,
132
132
  details: [
133
- 'setup: ' + p.setup.phase + (p.setup.phase === 'ready' ? '' : ' - ' + p.setup.message),
133
+ 'Setup: ' + p.setup.phase + (p.setup.phase === 'ready' ? '' : ' - ' + p.setup.message),
134
134
  ...(p.databases.length
135
- ? ['databases: ' + p.databases.map((d) => d.database + ' :' + d.port + ' ' + d.state).join(', ')]
135
+ ? ['Databases: ' + p.databases.map((d) => d.database + ' :' + d.port + ' ' + d.state).join(', ')]
136
136
  : []),
137
- ...p.sessions.map((s) => 'session ' + s.id + ' ' + s.harness + ' ' + s.viewers + ' ' + ago(s.started)),
138
- ...p.repoGuard.map((f) => 'guard: ' + f),
137
+ ...p.sessions.map((s) => 'Session: ' + s.harness + ' ' + s.id + ', ' + s.viewers + ', started ' + ago(s.started)),
138
+ ...p.repoGuard.map((f) => 'Guard: ' + f),
139
139
  ],
140
140
  };
141
141
  }
@@ -8,7 +8,7 @@ export interface WebRunOptions {
8
8
  readonly staticRoot: string;
9
9
  /** This program's version, kept in the state file. */
10
10
  readonly version: string;
11
- /** End after this long unused while no sandbox runs (WebServerOptions.idleExitMs). */
11
+ /** End after this long with no browser (sooner with no sandbox): WebServerOptions.idleExitMs. */
12
12
  readonly idleExitMs?: number;
13
13
  }
14
14
  export declare class WebRunner {
@@ -4,8 +4,13 @@ import type { Endpoint } from '../../shared/pty/index.js';
4
4
  import type { WebBackend } from './backend.js';
5
5
  /** No inline script: the page and its script are files from this server. Inline styles are what xterm.js needs. */
6
6
  export declare const CONTENT_SECURITY_POLICY: string;
7
- /** The server ends after this long with no request, no socket and no sandbox running (it starts again with one). */
7
+ /**
8
+ * The server ends after this long with no browser connected and no request, even while a sandbox runs (sandbox web
9
+ * open and the next launch start it again), so it never sits in memory unused.
10
+ */
8
11
  export declare const IDLE_EXIT_MS: number;
12
+ /** ...and sooner when no sandbox runs either: there is nothing to look at. */
13
+ export declare const IDLE_NO_SANDBOX_MS: number;
9
14
  export declare const RPC_PATH = "/api/rpc";
10
15
  export interface WebServerOptions {
11
16
  /** 0 picks a free port (tests). */
@@ -20,8 +25,10 @@ export interface WebServerOptions {
20
25
  readonly dial?: (endpoint: Endpoint) => Promise<net.Socket>;
21
26
  /** The shared core's operations and queries the browser may run (none: /api/rpc is not served). */
22
27
  readonly rpc?: RpcHost;
23
- /** End by itself (onStop) after this long unused while no sandbox runs; 0 or missing: never (tests). */
28
+ /** End by itself (onStop) after this long with no browser, even while a sandbox runs; 0 or missing: never (tests). */
24
29
  readonly idleExitMs?: number;
30
+ /** ...or after this long when no sandbox runs either (default IDLE_NO_SANDBOX_MS; tests make it short). */
31
+ readonly idleNoSandboxMs?: number;
25
32
  /** Heartbeat period (tests make it short). */
26
33
  readonly heartbeatMs?: number;
27
34
  }
@@ -54,8 +61,10 @@ export declare class WebServer {
54
61
  * so they never hold a slot or a session connection.
55
62
  */
56
63
  private beat;
57
- /** Nothing has used the server for a while and no sandbox runs: it ends (the next launch starts it again). */
64
+ /** No browser has used the server for a while: it ends (sooner when no sandbox runs). */
58
65
  private checkIdle;
66
+ /** How long unused before it ends when no sandbox runs. */
67
+ private quietMs;
59
68
  /** Counts a new socket; at the limit, the oldest of its kind makes room (a new tab always works). */
60
69
  private track;
61
70
  stop(): Promise<void>;
@@ -19,8 +19,13 @@ const MAX_TERMINALS = 16;
19
19
  const MAX_RPC_SOCKETS = 8;
20
20
  /** Every socket is pinged this often; one that has not answered the last ping by the next is dropped. */
21
21
  const HEARTBEAT_MS = 20_000;
22
- /** The server ends after this long with no request, no socket and no sandbox running (it starts again with one). */
23
- export const IDLE_EXIT_MS = 30 * 60_000;
22
+ /**
23
+ * The server ends after this long with no browser connected and no request, even while a sandbox runs (sandbox web
24
+ * open and the next launch start it again), so it never sits in memory unused.
25
+ */
26
+ export const IDLE_EXIT_MS = 20 * 60_000;
27
+ /** ...and sooner when no sandbox runs either: there is nothing to look at. */
28
+ export const IDLE_NO_SANDBOX_MS = 5 * 60_000;
24
29
  const SESSION_PATH = /^\/api\/sessions\/([A-Za-z0-9_-]{1,64})\/terminal$/;
25
30
  export const RPC_PATH = '/api/rpc';
26
31
  class HttpFailure extends Error {
@@ -72,7 +77,7 @@ export class WebServer {
72
77
  this.heartbeat = setInterval(() => this.beat(), this.options.heartbeatMs ?? HEARTBEAT_MS);
73
78
  this.heartbeat.unref();
74
79
  if (this.options.idleExitMs) {
75
- this.idleCheck = setInterval(() => void this.checkIdle(), Math.min(60_000, this.options.idleExitMs));
80
+ this.idleCheck = setInterval(() => void this.checkIdle(), Math.min(60_000, this.quietMs()));
76
81
  this.idleCheck.unref();
77
82
  }
78
83
  resolve();
@@ -102,20 +107,29 @@ export class WebServer {
102
107
  }
103
108
  }
104
109
  }
105
- /** Nothing has used the server for a while and no sandbox runs: it ends (the next launch starts it again). */
110
+ /** No browser has used the server for a while: it ends (sooner when no sandbox runs). */
106
111
  async checkIdle() {
107
112
  const limit = this.options.idleExitMs ?? 0;
108
- if (!limit || this.tracked.size > 0 || Date.now() - this.lastUsed < limit)
113
+ if (!limit || this.tracked.size > 0)
114
+ return;
115
+ const idle = Date.now() - this.lastUsed;
116
+ if (idle >= limit)
117
+ return this.options.onStop?.();
118
+ if (idle < this.quietMs())
109
119
  return;
110
120
  try {
111
121
  if ((await this.options.backend.info()).sandbox === 'running')
112
122
  return;
113
123
  }
114
124
  catch {
115
- return; // cannot tell: keep running
125
+ return; // cannot tell: wait for the longer limit
116
126
  }
117
127
  this.options.onStop?.();
118
128
  }
129
+ /** How long unused before it ends when no sandbox runs. */
130
+ quietMs() {
131
+ return Math.min(this.options.idleExitMs ?? 0, this.options.idleNoSandboxMs ?? IDLE_NO_SANDBOX_MS);
132
+ }
119
133
  /** Counts a new socket; at the limit, the oldest of its kind makes room (a new tab always works). */
120
134
  track(ws, kind, limit) {
121
135
  const same = [...this.tracked.entries()]
@@ -32063,6 +32063,7 @@ var connectOnce = (host, port, timeoutMs) => {
32063
32063
  socket.once("connect", () => {
32064
32064
  clearTimeout(timer);
32065
32065
  socket.setNoDelay(true);
32066
+ socket.setKeepAlive(true, KEEPALIVE_IDLE_MS);
32066
32067
  resolve2(socket);
32067
32068
  });
32068
32069
  socket.once("error", (e) => {
@@ -32071,6 +32072,7 @@ var connectOnce = (host, port, timeoutMs) => {
32071
32072
  });
32072
32073
  });
32073
32074
  };
32075
+ var KEEPALIVE_IDLE_MS = 1e4;
32074
32076
  async function connect(endpoint, timeoutMs = 3e3) {
32075
32077
  const errors = [];
32076
32078
  for (const address of endpoint.addresses) {
@@ -34686,6 +34686,7 @@ var connectOnce = (host, port, timeoutMs) => {
34686
34686
  socket.once("connect", () => {
34687
34687
  clearTimeout(timer);
34688
34688
  socket.setNoDelay(true);
34689
+ socket.setKeepAlive(true, KEEPALIVE_IDLE_MS);
34689
34690
  resolve2(socket);
34690
34691
  });
34691
34692
  socket.once("error", (e) => {
@@ -34694,6 +34695,7 @@ var connectOnce = (host, port, timeoutMs) => {
34694
34695
  });
34695
34696
  });
34696
34697
  };
34698
+ var KEEPALIVE_IDLE_MS = 1e4;
34697
34699
  async function connect(endpoint, timeoutMs = 3e3) {
34698
34700
  const errors = [];
34699
34701
  for (const address of endpoint.addresses) {
@@ -5594,6 +5594,7 @@ var connectOnce = (host, port, timeoutMs) => {
5594
5594
  socket.once("connect", () => {
5595
5595
  clearTimeout(timer);
5596
5596
  socket.setNoDelay(true);
5597
+ socket.setKeepAlive(true, KEEPALIVE_IDLE_MS);
5597
5598
  resolve(socket);
5598
5599
  });
5599
5600
  socket.once("error", (e) => {
@@ -5602,6 +5603,7 @@ var connectOnce = (host, port, timeoutMs) => {
5602
5603
  });
5603
5604
  });
5604
5605
  };
5606
+ var KEEPALIVE_IDLE_MS = 1e4;
5605
5607
  async function connect(endpoint, timeoutMs = 3e3) {
5606
5608
  const errors = [];
5607
5609
  for (const address of endpoint.addresses) {
@@ -524,6 +524,41 @@ async function sharedCore(t) {
524
524
  t.equal(keys, ['f', 'l'], 'the health keys are f and l (e only when Windows Sandbox can be turned on)');
525
525
  const canEnable = healthPage({ last: { ...saved, setup: { ...saved.setup, canEnable: true } } });
526
526
  t.equal(menuView(canEnable, { category: 'health', refresh: false }).actions.map((a) => a.key), ['f', 'l', 'e'], 'e turns Windows Sandbox on when the setup says it can be');
527
+ const problems = healthPage({
528
+ last: {
529
+ ...saved,
530
+ mode: 'repair',
531
+ checks: [
532
+ {
533
+ id: 'stale',
534
+ group: 'Data folder',
535
+ name: 'Stale run folders',
536
+ status: 'needs-you',
537
+ detail: 'two folders from a sandbox that did not close',
538
+ means: 'they take space',
539
+ fix: 'stop the sandbox',
540
+ lines: ['run-20261012-0931'],
541
+ fixable: false,
542
+ },
543
+ {
544
+ id: 'net',
545
+ group: 'Network',
546
+ name: 'GitHub',
547
+ status: 'warn',
548
+ detail: 'no answer',
549
+ fix: 'check your VPN',
550
+ fixable: false,
551
+ },
552
+ ],
553
+ },
554
+ });
555
+ const repairItems = problems.sections.find((s) => s.id === 'repair')?.items ?? [];
556
+ const stale = repairItems[0];
557
+ t.equal(stale?.details, ['Found: two folders from a sandbox that did not close', 'Fix: stop the sandbox', 'run-20261012-0931'], 'a repair row lists what was found as a Found: line, then the fix and the paths (ids stay plain)');
558
+ t.equal(stale?.description, 'they take space', 'a repair row with a meaning shows it as the description');
559
+ const net = repairItems[1];
560
+ t.equal(net?.details, ['Found: no answer', 'Fix: check your VPN'], 'a repair row without a meaning still says Found:');
561
+ t.equal(net?.description, undefined, 'without a meaning, the found text is not repeated as the description');
527
562
  }
528
563
  export const healthSuite = {
529
564
  name: 'health',
@@ -90,6 +90,36 @@ export const liveTerminalSuite = {
90
90
  t.check(!errors, 'ptyhost and the stand-in agent compile', errors);
91
91
  if (errors)
92
92
  return;
93
+ t.section('what a program leaves running ends with it (no leftover processes)');
94
+ {
95
+ // A copy of PING with a name of its own, started detached and hidden (its own console, so closing the pseudo
96
+ // console does not end it), by a program that then exits.
97
+ const probe = t.at('leftover-probe.exe');
98
+ fs.copyFileSync(path.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'PING.EXE'), probe);
99
+ const starter = '"' +
100
+ SYSTEM.powershell() +
101
+ '" -NoProfile -Command "Start-Process -WindowStyle Hidden -FilePath \'' +
102
+ probe +
103
+ "' -ArgumentList '-n','120','127.0.0.1'; Start-Sleep -Seconds 5\"";
104
+ const host = spawn(ptyhost, [String(await freePort()), 'tok', '80', '24', t.at('leftover.log'), starter], {
105
+ env: { ...process.env, PTYHOST_BIND: 'loopback' },
106
+ stdio: 'ignore',
107
+ windowsHide: true,
108
+ });
109
+ const probeRunning = () => spawnSync(SYSTEM.tasklist(), ['/FI', 'IMAGENAME eq leftover-probe.exe', '/NH'], { encoding: 'utf8' })
110
+ .stdout.toLowerCase()
111
+ .includes('leftover-probe.exe');
112
+ await t.waitFor(probeRunning, WAIT.screen);
113
+ t.check(probeRunning(), 'the program started a background process of its own');
114
+ await t.waitFor(() => host.exitCode !== null, 30000);
115
+ await t.waitFor(() => !probeRunning(), 5000);
116
+ const log = fs.existsSync(t.at('leftover.log')) ? fs.readFileSync(t.at('leftover.log'), 'utf8') : '';
117
+ t.check(!probeRunning() && /ended [1-9]\d* process\(es\) the program left running/.test(log), 'when the program exits, the background process it left running is ended too', log.slice(-400));
118
+ if (probeRunning())
119
+ spawnSync(path.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'taskkill.exe'), ['/F', '/IM', 'leftover-probe.exe'], {
120
+ stdio: 'ignore',
121
+ });
122
+ }
93
123
  t.section("the agents' job: a last-resort backstop, set once");
94
124
  {
95
125
  // A job of the test's own (PTYHOST_JOB_NAME), never the agents' real one.
@@ -575,6 +575,8 @@ function previewRpc(ctx, state) {
575
575
  return { message: 'Done. Restart Windows to finish. (preview: nothing was changed)' };
576
576
  case 'engine.status':
577
577
  return sampleEngines();
578
+ case 'engine.leftovers':
579
+ return { available: true, message: 'No HCS systems of yours are running.', systems: [] };
578
580
  case 'logs.list':
579
581
  return sampleLogs();
580
582
  case 'logs.tail':
@@ -789,6 +791,7 @@ function previewRpc(ctx, state) {
789
791
  'logs.list': 'query',
790
792
  'logs.tail': 'query',
791
793
  'engine.status': 'query',
794
+ 'engine.leftovers': 'query',
792
795
  };
793
796
  return {
794
797
  kind: (id) => kinds[id],
@@ -245,6 +245,39 @@ export const webSuite = {
245
245
  await new Promise((r) => setTimeout(r, 300));
246
246
  t.check(hub.size === 0 && reads === stopped, 'with nobody watching, nothing is read');
247
247
  }
248
+ t.section('an unused server ends by itself (no process left behind)');
249
+ {
250
+ const idleServer = async (sandbox, limits) => {
251
+ let ended = false;
252
+ const idle = new WebServer({
253
+ port: 0,
254
+ token: TOKEN,
255
+ backend: {
256
+ info: () => Promise.resolve({
257
+ sandbox,
258
+ owner: null,
259
+ canUse: true,
260
+ harnesses: [],
261
+ defaultHarness: 'claude',
262
+ projects: [],
263
+ }),
264
+ sessions: () => Promise.resolve([]),
265
+ endpoint: () => null,
266
+ },
267
+ staticRoot: t.dir('idle-static-' + sandbox + limits.exit),
268
+ idleExitMs: limits.exit,
269
+ idleNoSandboxMs: limits.quiet,
270
+ onStop: () => (ended = true),
271
+ });
272
+ await idle.start();
273
+ await new Promise((r) => setTimeout(r, 1500));
274
+ await idle.stop();
275
+ return ended;
276
+ };
277
+ t.check(await idleServer('stopped', { exit: 60_000, quiet: 200 }), 'no browser and no sandbox: it ends after the short wait');
278
+ t.check(!(await idleServer('running', { exit: 60_000, quiet: 200 })), 'a sandbox runs: the short wait does not end it');
279
+ t.check(await idleServer('running', { exit: 300, quiet: 200 }), 'no browser for the long wait: it ends even while a sandbox runs');
280
+ }
248
281
  t.section('the server');
249
282
  const fake = new FakePtyHost();
250
283
  await fake.start();
@@ -4,7 +4,6 @@ export interface Endpoint {
4
4
  port: number;
5
5
  token: string;
6
6
  }
7
- /** Tries every address the sandbox published and returns the first one that answers. */
8
7
  export declare function connect(endpoint: Endpoint, timeoutMs?: number): Promise<net.Socket>;
9
8
  /** Sends one control frame (e.g. RESTART) without taking over the screen. */
10
9
  export declare function sendControl(endpoint: Endpoint, type: number): Promise<void>;
@@ -10,6 +10,9 @@ const connectOnce = (host, port, timeoutMs) => {
10
10
  socket.once('connect', () => {
11
11
  clearTimeout(timer);
12
12
  socket.setNoDelay(true);
13
+ // A sandbox that vanished (closed hard, ended in Task Manager) never closes the connection: keepalive notices
14
+ // within seconds, so an attached terminal or the web's bridge ends instead of hanging on.
15
+ socket.setKeepAlive(true, KEEPALIVE_IDLE_MS);
13
16
  resolve(socket);
14
17
  });
15
18
  socket.once('error', (e) => {
@@ -19,6 +22,8 @@ const connectOnce = (host, port, timeoutMs) => {
19
22
  });
20
23
  };
21
24
  /** Tries every address the sandbox published and returns the first one that answers. */
25
+ /** Idle time before TCP keepalive probes start (the same as ptyhost's own side). */
26
+ const KEEPALIVE_IDLE_MS = 10_000;
22
27
  export async function connect(endpoint, timeoutMs = 3000) {
23
28
  const errors = [];
24
29
  for (const address of endpoint.addresses) {
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>Sandbox</title>
7
7
  <link rel="icon" href="data:," />
8
- <link rel="stylesheet" href="/main-MU5XWZMS.css" />
9
- <script type="module" src="/main-SLQNJRXD.js"></script>
8
+ <link rel="stylesheet" href="/main-RQFN4WBY.css" />
9
+ <script type="module" src="/main-QL7KHCEE.js"></script>
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>