dsh-builtin-browser 0.4.1 → 0.4.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.
Files changed (44) hide show
  1. package/README.en.md +61 -23
  2. package/README.md +59 -23
  3. package/client.js +60 -5
  4. package/cordis.patch.yml +21 -0
  5. package/desktop-bridge/plugin-browser-bridge.js +12 -4
  6. package/docs/architecture.md +12 -2
  7. package/docs/tool-reference.md +12 -12
  8. package/docs/user-guide.md +10 -3
  9. package/docs/why-browser.md +1 -1
  10. package/lib/browser/types.d.ts +25 -1
  11. package/lib/browser-electron/bridge-connection.d.ts +31 -5
  12. package/lib/browser-electron/bridge-connection.js +143 -21
  13. package/lib/browser-electron/entry.d.ts +1 -1
  14. package/lib/browser-electron/entry.js +61 -35
  15. package/lib/browser-electron/ephemeral-profile.d.ts +37 -0
  16. package/lib/browser-electron/ephemeral-profile.js +146 -0
  17. package/lib/browser-electron/history-store.d.ts +25 -0
  18. package/lib/browser-electron/history-store.js +51 -4
  19. package/lib/browser-electron/host-main.js +75 -27
  20. package/lib/browser-electron/provider.d.ts +44 -1
  21. package/lib/browser-electron/provider.js +379 -48
  22. package/lib/browser-electron/remote-host.d.ts +52 -3
  23. package/lib/browser-electron/remote-host.js +244 -23
  24. package/lib/browser-electron/settings-route.d.ts +28 -0
  25. package/lib/browser-electron/settings-route.js +28 -0
  26. package/lib/browser-electron/settings-store.d.ts +40 -0
  27. package/lib/browser-electron/settings-store.js +107 -11
  28. package/lib/browser-electron/system-browser.d.ts +71 -3
  29. package/lib/browser-electron/system-browser.js +311 -40
  30. package/lib/tool-browser/index.js +142 -33
  31. package/package.json +5 -3
  32. package/scripts/verify-host-compat.mjs +195 -0
  33. package/src/browser/types.ts +25 -1
  34. package/src/browser-electron/bridge-connection.ts +142 -21
  35. package/src/browser-electron/entry.ts +58 -33
  36. package/src/browser-electron/ephemeral-profile.ts +152 -0
  37. package/src/browser-electron/history-store.ts +51 -4
  38. package/src/browser-electron/host-main.ts +105 -35
  39. package/src/browser-electron/provider.ts +401 -51
  40. package/src/browser-electron/remote-host.ts +261 -36
  41. package/src/browser-electron/settings-route.ts +46 -0
  42. package/src/browser-electron/settings-store.ts +121 -11
  43. package/src/browser-electron/system-browser.ts +302 -26
  44. package/src/tool-browser/index.ts +143 -35
@@ -18,18 +18,48 @@
18
18
  * @module dsh-browser/browser-electron/bridge-connection
19
19
  */
20
20
  import { connect } from 'node:net';
21
+ /**
22
+ * Safety cap on one un-answered reply line. CDP answers from the sidebar (a screenshot's
23
+ * base64 is the big one) cross this socket, so the cap is generous; what it exists for is a
24
+ * peer that never sends a newline — `this.buffer += chunk` grew without bound, unlike the
25
+ * same buffer in remote-host.ts, which caps at 512 MiB.
26
+ */
27
+ const MAX_BUFFER_BYTES = 128 * 1024 * 1024;
28
+ /**
29
+ * Field this client adds to every request so the shell can echo it back and never relies on
30
+ * byte order to pair an answer with its request. Loosely named, and additive on purpose: a
31
+ * bridge that does not know it simply ignores it (the current shipped shell does), and the
32
+ * client then falls back to arrival order, which is what the protocol guaranteed before.
33
+ */
34
+ const REQUEST_ID_FIELD = 'bridgeRequestId';
21
35
  /**
22
36
  * A reusable connection to the bridge.
23
37
  *
24
- * Requests are serialised by construction: one socket, one in-flight request at a
25
- * time, answered in order. A broken socket is discarded so the next call dials
26
- * again — a dead connection must never become a dead plugin.
38
+ * Requests may be in flight CONCURRENTLY, so answers are correlated by id rather than by
39
+ * position: `call()` stamps a unique {@link REQUEST_ID_FIELD} on the request and `onData()`
40
+ * resolves the entry that carries the echoed id. A bridge that does not echo it is still
41
+ * supported — its answer is handed to the oldest outstanding request, which is the
42
+ * arrival-order rule the protocol states. Once such an answer has been seen, the connection
43
+ * stops keeping two requests in flight (a second one re-dials), so position can never be the
44
+ * only thing distinguishing two callers' answers. An answer that matches nothing is never
45
+ * handed to whichever request happens to be first: the socket is dropped instead.
46
+ *
47
+ * A broken socket is discarded so the next call dials again — a dead connection must never
48
+ * become a dead plugin.
27
49
  */
28
50
  export class BridgeConnection {
29
51
  endpoint;
30
52
  socket;
31
53
  buffer = '';
54
+ /** Outstanding requests by id. */
55
+ pending = new Map();
56
+ /** Ids of the requests sent on the current socket, oldest first. */
32
57
  queue = [];
58
+ nextId = 1;
59
+ /** True once any reply arrived without an id: this bridge does not echo request ids. */
60
+ idLessBridge = false;
61
+ /** True once this connection may only have one request in flight at a time. */
62
+ serialisable = false;
33
63
  /**
34
64
  * @param endpoint - the shell's published bridge endpoint.
35
65
  */
@@ -38,33 +68,67 @@ export class BridgeConnection {
38
68
  }
39
69
  /**
40
70
  * Send one request, reusing the socket if it is still healthy.
41
- * @param request - the request body (the token is added here).
71
+ *
72
+ * Requests may overlap: the request is written as soon as it is queued, so two concurrent
73
+ * callers have two requests in flight. Each carries a unique {@link REQUEST_ID_FIELD}, and
74
+ * `onData()` resolves the entry that carries the echoed id — which is what makes
75
+ * correlation independent of arrival order. A bridge that does not echo the field (the
76
+ * shipped shell today) still works by arrival order; see {@link onData}.
77
+ * @param request - the request body (the token and the request id are added here).
42
78
  * @param timeoutMs - how long to wait for the answer before dropping the socket.
43
79
  * @returns the bridge's answer.
44
80
  */
45
81
  async call(request, timeoutMs = 20_000) {
46
- const socket = this.ensureSocket();
82
+ // One re-dial, and only for the case this exists for: a socket that has already carried
83
+ // an id-less answer cannot have a second request in flight, because two outstanding
84
+ // requests would be matched by position — the swap this correlation prevents. It is a
85
+ // straight-line re-dial rather than a retry loop: an earlier revision re-entered call()
86
+ // from inside its own promise executor, which left an orphaned pending entry behind and,
87
+ // when the condition could not clear, spun until the process ran out of heap.
88
+ let socket = this.ensureSocket();
89
+ if (this.serialisable && this.queue.length > 0) {
90
+ this.reset(new Error('dsh-builtin-browser: bridge connection reopened so replies stay attributable'));
91
+ socket = this.ensureSocket();
92
+ }
93
+ const id = this.nextId++;
47
94
  return await new Promise((resolve, reject) => {
95
+ this.pending.set(id, {
96
+ resolve: answer => { clearTimeout(timer); resolve(answer); },
97
+ reject: error => { clearTimeout(timer); reject(error); },
98
+ });
99
+ this.queue.push(id);
48
100
  const timer = setTimeout(() => {
49
- const index = this.queue.findIndex(entry => entry.resolve === resolve);
50
- if (index !== -1)
51
- this.queue.splice(index, 1);
52
101
  const error = new Error(`dsh-builtin-browser: bridge timed out after ${timeoutMs}ms`);
53
- this.reset(error);
102
+ // An unanswered request desynchronises the stream for an id-less bridge, and leaves one
103
+ // outstanding for one that echoes ids — either way the next call should start on a
104
+ // fresh socket. Only when THIS request's socket is still the live one: tearing down the
105
+ // REPLACEMENT because an abandoned request timed out would break whatever is using it.
106
+ //
107
+ // This test used to read `this.pending.has(id) && this.socket === socket`, with
108
+ // `forget(id)` on the line above it — and forget() is what removes the entry that `has`
109
+ // was looking for, so the condition was false every single time and the rebuild never
110
+ // ran. A timed-out call therefore left the desynchronised socket in place, and every
111
+ // later call inherited exactly the ambiguity this reset exists to clear. Deciding
112
+ // before forgetting is the whole fix; the `else` still drops the stale entry.
113
+ if (this.socket === socket)
114
+ this.reset(error);
115
+ else
116
+ this.forget(id);
117
+ // reset() already rejected this entry through its wrapper when it ran, so this is
118
+ // either the only rejection or a no-op that guarantees the caller settles.
54
119
  reject(error);
55
120
  }, timeoutMs);
56
- this.queue.push({
57
- resolve: answer => { clearTimeout(timer); resolve(answer); },
58
- reject: error => { clearTimeout(timer); reject(error); },
59
- });
60
121
  try {
61
- socket.write(JSON.stringify({ token: this.endpoint.token, ...request }) + '\n');
122
+ // The id is added LAST so a request body can never shadow it, and the token is
123
+ // added here rather than by the caller.
124
+ socket.write(JSON.stringify({ token: this.endpoint.token, ...request, [REQUEST_ID_FIELD]: id }) + '\n');
62
125
  }
63
126
  catch (error) {
64
127
  clearTimeout(timer);
65
- this.queue.pop();
128
+ this.forget(id);
66
129
  const failure = error instanceof Error ? error : new Error(String(error));
67
- this.reset(failure);
130
+ if (this.socket === socket)
131
+ this.reset(failure);
68
132
  reject(failure);
69
133
  }
70
134
  });
@@ -80,16 +144,33 @@ export class BridgeConnection {
80
144
  const socket = connect({ host: '127.0.0.1', port: this.endpoint.port });
81
145
  socket.setEncoding('utf8');
82
146
  socket.on('data', chunk => this.onData(String(chunk)));
83
- socket.on('error', error => this.reset(error));
84
- socket.on('close', () => this.reset(new Error('dsh-builtin-browser: bridge connection closed')));
147
+ // Both handlers pass the socket they belong to, so a late event from a socket that was
148
+ // already replaced cannot tear down its successor: 'close' still fires for a socket that
149
+ // was removed mid-recycle, and acting on it there would kill a perfectly good connection.
150
+ socket.on('error', error => this.socketFailed(socket, error));
151
+ socket.on('close', () => this.socketFailed(socket, new Error('dsh-builtin-browser: bridge connection closed')));
85
152
  this.buffer = '';
86
153
  this.socket = socket;
87
154
  socket.write(JSON.stringify({ token: this.endpoint.token }) + '\n');
88
155
  return socket;
89
156
  }
90
- /** Resolve queued requests in order as answers arrive. */
157
+ /** Tear the connection down for an event raised by `source`, unless it is already gone. */
158
+ socketFailed(source, error) {
159
+ if (this.socket !== source)
160
+ return;
161
+ this.reset(error);
162
+ }
163
+ /** Resolve queued requests by id, or in order when the bridge does not echo one. */
91
164
  onData(chunk) {
92
165
  this.buffer += chunk;
166
+ // A peer that never sends a newline must not grow this buffer without bound. Failing
167
+ // the connection is the honest outcome: the caller gets an error naming the cap, while
168
+ // the next call dials a fresh socket.
169
+ if (this.buffer.length > MAX_BUFFER_BYTES) {
170
+ this.buffer = '';
171
+ this.reset(new Error(`dsh-builtin-browser: bridge reply exceeded ${String(MAX_BUFFER_BYTES)} bytes`));
172
+ return;
173
+ }
93
174
  let newline = this.buffer.indexOf('\n');
94
175
  while (newline !== -1) {
95
176
  const line = this.buffer.slice(0, newline).trim();
@@ -97,9 +178,28 @@ export class BridgeConnection {
97
178
  newline = this.buffer.indexOf('\n');
98
179
  if (line === '')
99
180
  continue;
100
- const entry = this.queue.shift();
181
+ const echoed = echoOf(line);
182
+ const id = echoed ?? this.queue[0];
183
+ if (id === undefined) {
184
+ // Nothing is waiting for this answer: the peer is answering something this socket
185
+ // never asked (or answered twice). Drop the socket rather than trust the stream.
186
+ this.reset(new Error('dsh-builtin-browser: bridge answered with no request outstanding'));
187
+ return;
188
+ }
189
+ const index = this.queue.indexOf(id);
190
+ if (index !== -1)
191
+ this.queue.splice(index, 1);
192
+ const entry = this.pending.get(id);
101
193
  if (entry === undefined)
102
194
  continue;
195
+ this.pending.delete(id);
196
+ // Id-less bridges pair by order, so a reply for the WRONG request cannot be told apart
197
+ // from the right one. Remember that this bridge does not echo ids, and serialise every
198
+ // later call so the ambiguity cannot arise again.
199
+ if (echoed === undefined) {
200
+ this.idLessBridge = true;
201
+ this.serialisable = true;
202
+ }
103
203
  try {
104
204
  const answer = JSON.parse(line);
105
205
  // A rejected token means the shell restarted with a new one, so this socket
@@ -117,6 +217,13 @@ export class BridgeConnection {
117
217
  }
118
218
  }
119
219
  }
220
+ /** Forget one request (its answer can no longer be matched). */
221
+ forget(id) {
222
+ this.pending.delete(id);
223
+ const index = this.queue.indexOf(id);
224
+ if (index !== -1)
225
+ this.queue.splice(index, 1);
226
+ }
120
227
  /** Tear the socket down, failing pending work. */
121
228
  reset(error) {
122
229
  if (this.socket !== undefined) {
@@ -126,8 +233,23 @@ export class BridgeConnection {
126
233
  socket.destroy();
127
234
  }
128
235
  this.buffer = '';
129
- const pending = this.queue.splice(0, this.queue.length);
236
+ this.queue.length = 0;
237
+ const pending = [...this.pending.values()];
238
+ this.pending.clear();
130
239
  for (const entry of pending)
131
240
  entry.reject(error);
132
241
  }
133
242
  }
243
+ /** The request id a raw answer line echoes back, when it carries one. */
244
+ function echoOf(line) {
245
+ try {
246
+ const answer = JSON.parse(line);
247
+ const value = answer[REQUEST_ID_FIELD];
248
+ return typeof value === 'number' && Number.isSafeInteger(value) ? value : undefined;
249
+ }
250
+ catch {
251
+ // A malformed line cannot be matched by id; the caller consumes it in order and
252
+ // reports the parse failure itself.
253
+ return undefined;
254
+ }
255
+ }
@@ -34,7 +34,7 @@ export interface Config {
34
34
  readonly downloadDir?: string;
35
35
  /** Maximum snapshot elements before truncation. Default 60. */
36
36
  readonly snapshotMaxElements?: number;
37
- /** Maximum content characters before truncation when no maxChars is given. Default 100_000. */
37
+ /** Maximum content characters before truncation when no maxChars is given. No longer read: the cap is per format (html and json 50 000, otherwise 20 000). */
38
38
  readonly contentMaxChars?: number;
39
39
  }
40
40
  export declare const Config: z<Config>;
@@ -17,8 +17,10 @@ import { join } from 'node:path';
17
17
  import { defaultHostMainPath, RemoteElectronViewHost } from './remote-host.js';
18
18
  import { DesktopBridgeViewHost } from './desktop-bridge-host.js';
19
19
  import { detectBrowser, searchSummary, SystemBrowserViewHost } from './system-browser.js';
20
+ import { claimEphemeralProfile, ephemeralProfileName, sweepAbandonedEphemeralProfiles } from './ephemeral-profile.js';
20
21
  import { MissingSystemBrowserHost } from './missing-system-browser.js';
21
22
  import { SettingsStore } from './settings-store.js';
23
+ import { sameOrigin } from './settings-route.js';
22
24
  export { ELECTRON_BROWSER_PROVIDER_ID, ElectronBrowserProvider, } from './provider.js';
23
25
  export { RemoteElectronViewHost, defaultHostMainPath } from './remote-host.js';
24
26
  /** Cordis plugin name used by loader diagnostics. */
@@ -83,7 +85,14 @@ export function apply(ctx, config) {
83
85
  ctx.logger?.info?.(`dsh-builtin-browser: ${note}`);
84
86
  }
85
87
  catch (error) {
86
- // Never leave the surface without a provider.
88
+ // Never leave the surface without a provider. The half-built host is disposed FIRST: an
89
+ // adopt that got as far as constructing it may already hold a socket, a listener or a child
90
+ // process, and dropping it on the floor is how a failed upgrade becomes a leak that only
91
+ // shows up as a stray Electron in the task manager.
92
+ try {
93
+ host.dispose?.();
94
+ }
95
+ catch { /* the fallback below matters more than this */ }
87
96
  unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(new RemoteElectronViewHost(defaultHostMainPath()), providerConfig));
88
97
  ctx.logger?.warn?.(`dsh-builtin-browser: could not adopt ${note} (${String(error)})`);
89
98
  }
@@ -118,15 +127,29 @@ export function apply(ctx, config) {
118
127
  }
119
128
  }
120
129
  else {
121
- const home = process.env.DSH_HOME ?? homedir();
130
+ // Same profile root as settings-store, history-store and desktop-bridge-host: the
131
+ // `?.dsh` component used to be missing HERE only, so a desktop launched from a
132
+ // shortcut (no DSH_HOME in the environment) put its browser profile in
133
+ // `C:\Users\<user>\dsh-builtin-browser-host\` while its settings and history lived in
134
+ // `~/.dsh/dsh-builtin-browser-host/` — two half-profiles that never met, and a
135
+ // settings panel whose switches appeared not to apply.
136
+ const home = process.env.DSH_HOME ?? join(homedir(), '.dsh');
122
137
  // Login state lives in the browser profile, so the cookies setting decides where
123
138
  // that profile goes: a stable directory keeps the user signed in across restarts
124
139
  // (the point of using their own browser), while turning persistence off gets a
125
140
  // throwaway directory that is removed when the browser is released.
126
141
  const persist = settings.get().cookies.persist;
142
+ const profileRoot = join(home, BROWSER_PROFILE_DIR);
127
143
  const profileDir = persist
128
- ? join(home, 'dsh-builtin-browser-host', `${detected.kind}-profile`)
129
- : join(home, 'dsh-builtin-browser-host', `${detected.kind}-profile-ephemeral-${randomUUID()}`);
144
+ ? join(profileRoot, `${detected.kind}-profile`)
145
+ : join(profileRoot, ephemeralProfileName(detected.kind, randomUUID()));
146
+ if (!persist) {
147
+ // A process killed outright never runs its own cleanup (see ephemeral-profile.ts),
148
+ // so the sweep happens HERE — at the only moment that can still act on a profile
149
+ // whose owner is gone — and before this run claims a directory of its own.
150
+ sweepAbandonedEphemeralProfiles(profileRoot, detected.kind);
151
+ claimEphemeralProfile(profileDir);
152
+ }
130
153
  adopt(new SystemBrowserViewHost(detected, profileDir, [], persist ? undefined : profileDir), `will drive the installed ${detected.kind} (${detected.path}${persist ? '' : ', ephemeral profile'})`);
131
154
  }
132
155
  }
@@ -134,16 +157,41 @@ export function apply(ctx, config) {
134
157
  // replace it, which is exactly what this guard prevents.
135
158
  if (explicitChoice)
136
159
  return;
137
- void DesktopBridgeViewHost.discover().then(sidebar => {
138
- // No bridge (plain `dsh web`, an older desktop build, or a shell that already
139
- // exited): keep self-hosting, which is what every surface has always done.
140
- if (sidebar === undefined)
160
+ // The shell may not be up yet. `dsh web` starts before — or entirely without — the desktop app
161
+ // that hosts the bridge, and a one-shot discovery meant a bridge that appeared a second later
162
+ // was never adopted for the rest of the process's life. Retry a handful of times over roughly
163
+ // half a minute, then stop: this is a convenience, not a promise, and the session the user is
164
+ // already driving in the self-hosted window is not worth replacing minutes into their work.
165
+ // `adopt` is itself one-shot (its `upgraded` guard), so the only possible effect of a retry
166
+ // that lands late is adopting a bridge that showed up just after startup.
167
+ void (async () => {
168
+ for (const delay of [0, 2_000, 5_000, 15_000]) {
169
+ if (delay > 0)
170
+ await new Promise(resolve => setTimeout(resolve, delay));
171
+ let sidebar;
172
+ try {
173
+ sidebar = await DesktopBridgeViewHost.discover();
174
+ }
175
+ catch {
176
+ sidebar = undefined; // discovery is documented not to throw; never let it stop the retries
177
+ }
178
+ if (sidebar === undefined)
179
+ continue;
180
+ adopt(sidebar, 'driving the desktop sidebar browser');
141
181
  return;
142
- adopt(sidebar, 'driving the desktop sidebar browser');
143
- }).catch(() => { });
182
+ }
183
+ })();
144
184
  }
145
185
  /** Route the settings panel reads and writes. */
146
186
  const SETTINGS_ROUTE = '/dsh-builtin-browser/settings';
187
+ /**
188
+ * The directory under the DSH home that holds this plugin's browser profile.
189
+ *
190
+ * Named once, because every consumer of it must agree: `settingsPath()` and the history
191
+ * store resolve their own copy from `$DSH_HOME`, and a profile written under a different
192
+ * root than the settings document is a pair that silently never meets.
193
+ */
194
+ const BROWSER_PROFILE_DIR = 'dsh-builtin-browser-host';
147
195
  /**
148
196
  * Expose the settings document over the host's web server, which is how the
149
197
  * settings panel reads and writes it. A host without that service (headless /
@@ -169,31 +217,9 @@ function installSettingsRoute(ctx, settings) {
169
217
  // No web server on this surface: the settings file remains the interface.
170
218
  }
171
219
  }
172
- /**
173
- * Same-origin guard. Settings are machine-local configuration, so a page loaded
174
- * elsewhere must not be able to read or rewrite them.
175
- * @param request - the incoming request.
176
- * @returns whether the request may touch the settings document.
177
- */
178
- function sameOrigin(request) {
179
- const site = String(request.headers['sec-fetch-site'] ?? '').toLowerCase();
180
- if (site === 'cross-site')
181
- return false;
182
- const origin = String(request.headers.origin ?? '').trim();
183
- if (origin === 'null')
184
- return false;
185
- if (origin === '')
186
- return true;
187
- try {
188
- return new URL(origin).host.toLowerCase() === String(request.headers.host ?? '').trim().toLowerCase();
189
- }
190
- catch {
191
- return false;
192
- }
193
- }
194
220
  /**
195
221
  * One settings round-trip: `GET` reads the document, `PUT`/`POST` merges a
196
- * partial patch into it.
222
+ * partial patch into it. Admission lives in `settings-route.ts` so it can be tested.
197
223
  * @param request - the incoming request.
198
224
  * @param response - the response to write.
199
225
  * @param settings - the settings document.
@@ -206,11 +232,11 @@ async function handleSettingsRequest(request, response, settings) {
206
232
  });
207
233
  response.end(JSON.stringify(body));
208
234
  };
209
- if (!sameOrigin(request)) {
235
+ const method = request.method ?? 'GET';
236
+ if (!sameOrigin(request, method !== 'GET')) {
210
237
  json(403, { ok: false, error: 'cross-origin request refused' });
211
238
  return;
212
239
  }
213
- const method = request.method ?? 'GET';
214
240
  if (method === 'GET') {
215
241
  json(200, { ok: true, settings: settings.get(), path: settings.path() });
216
242
  return;
@@ -0,0 +1,37 @@
1
+ /** Test seams; production passes neither. */
2
+ export interface SweepOptions {
3
+ /** The clock, for the age check. */
4
+ readonly now?: number;
5
+ /** Liveness test for an owner pid. */
6
+ readonly alive?: (pid: number) => boolean;
7
+ }
8
+ /**
9
+ * The directory name an ephemeral profile for `kind` gets, before it is claimed.
10
+ *
11
+ * The sweep matches on the same shape, so the two must agree: a name built anywhere else
12
+ * is a directory the sweep will never find.
13
+ * @param kind - the detected browser product (`chrome`, `edge`, ...).
14
+ * @param id - a per-run unique suffix.
15
+ * @returns the directory NAME, not a path.
16
+ */
17
+ export declare function ephemeralProfileName(kind: string, id: string): string;
18
+ /**
19
+ * Mark an ephemeral profile directory as this process's, so a later run can tell whether
20
+ * the process that created it is still there.
21
+ * @param dir - the profile directory (created if it does not exist yet).
22
+ * @param pid - the claiming process, defaulting to this one.
23
+ * @param now - the claim time, defaulting to the current clock.
24
+ * @returns true when the marker was written.
25
+ */
26
+ export declare function claimEphemeralProfile(dir: string, pid?: number, now?: number): boolean;
27
+ /**
28
+ * Delete the ephemeral profiles under `root` that belong to processes which are gone.
29
+ *
30
+ * Called before a new one is claimed, because that is the only moment that can still act
31
+ * on a profile whose owner was killed: nothing runs when the signal lands.
32
+ * @param root - the plugin's profile root (`<DSH_HOME>/dsh-builtin-browser-host`).
33
+ * @param kind - the browser product, which selects the directory-name prefix.
34
+ * @param options - test seams for the clock and the liveness test.
35
+ * @returns the names of the directories that were removed.
36
+ */
37
+ export declare function sweepAbandonedEphemeralProfiles(root: string, kind: string, options?: SweepOptions): string[];
@@ -0,0 +1,146 @@
1
+ /**
2
+ * The ephemeral browser profile: a throwaway directory under the DSH home, used when the
3
+ * user has turned profile persistence off, and deleted when the browser is released.
4
+ *
5
+ * Deleting on release is not enough by itself. A process killed outright — a DSH restart
6
+ * that ends in SIGKILL, "End task" in a task manager, a crashed host — never runs its
7
+ * cleanup, so the profile it was using survives on disk: the user asked for no profile to
8
+ * be kept and the machine keeps one anyway, holding cookies and history. Nothing can run
9
+ * at SIGKILL time, so the cleanup has to happen on the NEXT run instead, which is what
10
+ * this module provides.
11
+ *
12
+ * Ownership is a marker file inside the directory naming the process that claimed it. A
13
+ * directory whose owner is gone AND which has not been written to for a while is
14
+ * abandoned. The age check is what keeps the sweep away from a profile an orphaned
15
+ * browser (a child that outlived the parent which was killed) may still be using: a live
16
+ * Chromium writes to its profile continuously, so a directory that has not changed in an
17
+ * hour is not in use.
18
+ */
19
+ import { mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ /** The marker file, named once because both the claim and the sweep read it. */
22
+ const OWNER_FILE = '.dsh-ephemeral-owner';
23
+ /**
24
+ * How long a profile is left alone regardless of ownership. An orphaned browser keeps
25
+ * writing to its profile, so this is what distinguishes "abandoned" from "in use by a
26
+ * process this one did not start".
27
+ */
28
+ const ABANDONED_AFTER_MS = 60 * 60 * 1000;
29
+ /**
30
+ * Whether a process with this pid exists.
31
+ *
32
+ * A signal of 0 performs the permission and existence checks without delivering anything.
33
+ * EPERM is a live process owned by someone else — alive, not gone.
34
+ * @param pid - the pid to test.
35
+ * @returns true when the process exists.
36
+ */
37
+ function processAlive(pid) {
38
+ try {
39
+ process.kill(pid, 0);
40
+ return true;
41
+ }
42
+ catch (error) {
43
+ return error.code === 'EPERM';
44
+ }
45
+ }
46
+ /**
47
+ * The directory name an ephemeral profile for `kind` gets, before it is claimed.
48
+ *
49
+ * The sweep matches on the same shape, so the two must agree: a name built anywhere else
50
+ * is a directory the sweep will never find.
51
+ * @param kind - the detected browser product (`chrome`, `edge`, ...).
52
+ * @param id - a per-run unique suffix.
53
+ * @returns the directory NAME, not a path.
54
+ */
55
+ export function ephemeralProfileName(kind, id) {
56
+ return `${kind}-profile-ephemeral-${id}`;
57
+ }
58
+ /**
59
+ * Mark an ephemeral profile directory as this process's, so a later run can tell whether
60
+ * the process that created it is still there.
61
+ * @param dir - the profile directory (created if it does not exist yet).
62
+ * @param pid - the claiming process, defaulting to this one.
63
+ * @param now - the claim time, defaulting to the current clock.
64
+ * @returns true when the marker was written.
65
+ */
66
+ export function claimEphemeralProfile(dir, pid = process.pid, now = Date.now()) {
67
+ try {
68
+ mkdirSync(dir, { recursive: true });
69
+ writeFileSync(join(dir, OWNER_FILE), JSON.stringify({ pid, at: now }));
70
+ return true;
71
+ }
72
+ catch {
73
+ // The browser creates the directory itself when this fails, and cleanup is best
74
+ // effort throughout: a marker that could not be written must not stop the launch.
75
+ return false;
76
+ }
77
+ }
78
+ /**
79
+ * The claimed owner of an ephemeral profile, when it has one.
80
+ * @param dir - the profile directory.
81
+ * @returns the owner pid, or undefined when there is no readable marker.
82
+ */
83
+ function readOwner(dir) {
84
+ try {
85
+ const parsed = JSON.parse(readFileSync(join(dir, OWNER_FILE), 'utf8'));
86
+ return typeof parsed.pid === 'number' ? parsed.pid : undefined;
87
+ }
88
+ catch {
89
+ // No marker: a run from before this existed, or one killed before it wrote one. The
90
+ // age check has already applied, and a directory with no claimant that old is
91
+ // abandoned by any reading.
92
+ return undefined;
93
+ }
94
+ }
95
+ /**
96
+ * Delete the ephemeral profiles under `root` that belong to processes which are gone.
97
+ *
98
+ * Called before a new one is claimed, because that is the only moment that can still act
99
+ * on a profile whose owner was killed: nothing runs when the signal lands.
100
+ * @param root - the plugin's profile root (`<DSH_HOME>/dsh-builtin-browser-host`).
101
+ * @param kind - the browser product, which selects the directory-name prefix.
102
+ * @param options - test seams for the clock and the liveness test.
103
+ * @returns the names of the directories that were removed.
104
+ */
105
+ export function sweepAbandonedEphemeralProfiles(root, kind, options = {}) {
106
+ const now = options.now ?? Date.now();
107
+ const alive = options.alive ?? processAlive;
108
+ const prefix = `${kind}-profile-ephemeral-`;
109
+ const removed = [];
110
+ let names;
111
+ try {
112
+ names = readdirSync(root);
113
+ }
114
+ catch {
115
+ // No profile root yet: nothing has ever been written here, so nothing to sweep.
116
+ return removed;
117
+ }
118
+ for (const name of names) {
119
+ if (!name.startsWith(prefix))
120
+ continue;
121
+ const dir = join(root, name);
122
+ let ageMs;
123
+ try {
124
+ ageMs = now - statSync(dir).mtimeMs;
125
+ }
126
+ catch {
127
+ continue;
128
+ }
129
+ // Recent writes mean a browser is still working in there. A killed DSH can leave its
130
+ // Chromium child running, and that child's profile is not garbage.
131
+ if (ageMs < ABANDONED_AFTER_MS)
132
+ continue;
133
+ const owner = readOwner(dir);
134
+ if (owner !== undefined && alive(owner))
135
+ continue;
136
+ try {
137
+ rmSync(dir, { recursive: true, force: true });
138
+ removed.push(name);
139
+ }
140
+ catch {
141
+ // In use, or not ours to remove. Leaving it is the safe outcome and the next run
142
+ // tries again — the alternative is deleting a profile out from under a live browser.
143
+ }
144
+ }
145
+ return removed;
146
+ }
@@ -34,6 +34,22 @@ export declare function visitedHistoryPath(): string;
34
34
  */
35
35
  export declare class HistoryStore {
36
36
  private readonly file;
37
+ /**
38
+ * File size at which the next rewrite happens.
39
+ *
40
+ * Advanced after each rewrite so the cost is paid once per PRUNE_SLACK appends
41
+ * rather than on every append: asking "is it over the cap?" was true from the first
42
+ * over-cap write onwards, which is what made every later navigation rewrite the file.
43
+ */
44
+ private pruneThreshold;
45
+ /**
46
+ * Appends since the last rewrite.
47
+ *
48
+ * prune() runs after every navigation, and the answer is almost always "nothing to
49
+ * do" — but working that out from the file costs a full parse. Counting instead makes
50
+ * the common case a comparison.
51
+ */
52
+ private appendedSincePrune;
37
53
  private readonly maxEntries;
38
54
  private readonly maxAgeMs;
39
55
  /**
@@ -69,6 +85,15 @@ export declare class HistoryStore {
69
85
  * the count cap. Cheap no-op until the file grows past the cap, so callers can
70
86
  * run it after every append.
71
87
  */
88
+ /**
89
+ * How far past the cap the file may grow before it is rewritten.
90
+ *
91
+ * Scaled to the cap rather than fixed: a fixed 500 was meaningless when the cap is
92
+ * small (a caller asking for three entries would never reach 503), and the slack is
93
+ * there to batch rewrites, not to overrule the caller's bound.
94
+ * @returns the number of extra entries tolerated before a rewrite.
95
+ */
96
+ private slack;
72
97
  prune(): void;
73
98
  /** Parse the whole file, skipping blank and damaged lines. */
74
99
  private readAll;