@zgeoff/atc 2.28.0 → 2.29.0

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.
@@ -9,7 +9,17 @@ const MAX_QUEUED_BYTES = 64 * 1024;
9
9
  const writeAsync = promisify(write);
10
10
 
11
11
  /**
12
- * Makes a log that writes each line to a file descriptor without ever
12
+ * A log that never blocks the event loop. `log` queues one line, and
13
+ * `drain` resolves once every queued line is written or the given number of
14
+ * milliseconds has passed, whichever comes first.
15
+ */
16
+ export interface NonBlockingLog {
17
+ readonly log: (line: string) => void;
18
+ readonly drain: (timeoutMs: number) => Promise<void>;
19
+ }
20
+
21
+ /**
22
+ * Creates a log that writes each line to a file descriptor without ever
13
23
  * blocking the event loop, so a reader that stops reading stalls nothing
14
24
  * but the log. Each write goes through the asynchronous node:fs write,
15
25
  * which Bun runs on its thread pool, one write at a time. Lines that
@@ -17,13 +27,22 @@ const writeAsync = promisify(write);
17
27
  * next write after the drops starts with one `atc log dropped=N` line. A
18
28
  * failed write drops its lines and counts them the same way, and the next
19
29
  * line logged writes again.
30
+ *
31
+ * A write to a full pipe that nobody reads never returns, and it keeps the
32
+ * process alive until the process calls exit. A process that shuts down
33
+ * drains the log with a timeout and then exits: every queued line reaches a
34
+ * reader that keeps reading, and an unread pipe holds the exit back no
35
+ * longer than the timeout.
20
36
  */
21
- export function makeNonBlockingLog(fd: number): (line: string) => void {
37
+ export function createNonBlockingLog(fd: number): NonBlockingLog {
22
38
  let queue: string[] = [];
23
39
  let queuedBytes = 0;
24
40
  let dropped = 0;
25
41
  let writing = false;
26
42
 
43
+ // The drains that wait for the log to have nothing left to write.
44
+ let idleWaiters: (() => void)[] = [];
45
+
27
46
  const recordLine = (line: string): boolean => {
28
47
  const text = `${line}\n`;
29
48
  const bytes = Buffer.byteLength(text);
@@ -43,6 +62,8 @@ export function makeNonBlockingLog(fd: number): (line: string) => void {
43
62
  if (queue.length === 0 && dropped === 0) {
44
63
  writing = false;
45
64
 
65
+ releaseIdleWaiters();
66
+
46
67
  return;
47
68
  }
48
69
 
@@ -70,22 +91,59 @@ export function makeNonBlockingLog(fd: number): (line: string) => void {
70
91
  rest = rest.subarray(result.bytesWritten);
71
92
  }
72
93
  } catch {
94
+ // A drain stops waiting here too: the next write would most likely
95
+ // fail the same way, and the lines it would carry stay counted.
73
96
  dropped += lineCount;
74
97
  writing = false;
75
98
 
99
+ releaseIdleWaiters();
100
+
76
101
  return;
77
102
  }
78
103
 
79
104
  writeNext();
80
105
  };
81
106
 
82
- return (line) => {
83
- if (!recordLine(line)) {
84
- dropped++;
85
- }
107
+ const releaseIdleWaiters = (): void => {
108
+ const waiters = idleWaiters;
109
+
110
+ idleWaiters = [];
86
111
 
87
- if (!writing) {
88
- writeNext();
112
+ for (const resolve of waiters) {
113
+ resolve();
89
114
  }
90
115
  };
116
+
117
+ return {
118
+ log: (line) => {
119
+ if (!recordLine(line)) {
120
+ dropped++;
121
+ }
122
+
123
+ if (!writing) {
124
+ writeNext();
125
+ }
126
+ },
127
+ drain: async (timeoutMs) => {
128
+ // Lines left behind by a failed write wait for the next line logged,
129
+ // so a drain writes them itself.
130
+ if (!writing) {
131
+ writeNext();
132
+ }
133
+
134
+ if (!writing) {
135
+ return;
136
+ }
137
+
138
+ const idle = Promise.withResolvers<void>();
139
+
140
+ idleWaiters.push(idle.resolve);
141
+
142
+ const timer = setTimeout(idle.resolve, timeoutMs);
143
+
144
+ await idle.promise;
145
+
146
+ clearTimeout(timer);
147
+ },
148
+ };
91
149
  }
@@ -7,6 +7,7 @@ import { LineDecoder } from '../protocol/line-decoder';
7
7
  import { OutboundQueue } from '../protocol/outbound-queue';
8
8
  import type { SocketWriter } from '../protocol/outbound-queue';
9
9
  import { parseRequestParams } from '../protocol/parse-request-params';
10
+ import type { RequestMethod } from '../protocol/parse-request-params';
10
11
  import {
11
12
  MAX_CHUNK,
12
13
  MAX_LINE,
@@ -26,6 +27,7 @@ import { buildTargetForbiddenError } from './build-target-forbidden-error';
26
27
  import type { DaemonContext, SpawnParams } from './daemon-context';
27
28
  import type { TranscriptPosition } from './load-transcript-page';
28
29
  import { parseSpawnOverrides } from './parse-spawn-overrides';
30
+ import { REQUEST_ACCESS_CLASSES } from './request-access-classes';
29
31
  import { TargetAccess } from './target-access';
30
32
 
31
33
  // The targets a request may use and the namespace of its idempotency keys.
@@ -34,15 +36,6 @@ interface RequestScope {
34
36
  readonly keyNamespace: string;
35
37
  }
36
38
 
37
- // The requests that act on the whole daemon, or on the credentials a
38
- // session's host may use, which only its owner may make.
39
- const OWNER_METHODS: ReadonlySet<string> = new Set([
40
- 'daemon.quit',
41
- 'fleet.restore',
42
- 'session.auth.revoke',
43
- 'session.auth.rebind',
44
- ]);
45
-
46
39
  // What a limited connection is sent for one event, and the sessions that
47
40
  // event moved out of its view.
48
41
  interface ViewUpdate {
@@ -316,7 +309,7 @@ export class DaemonConnection {
316
309
 
317
310
  const scope = this.findRequestScope(req);
318
311
 
319
- if (scope !== null && OWNER_METHODS.has(req.m)) {
312
+ if (scope !== null && isOwnerOnlyMethod(req.m)) {
320
313
  this.sendErr(req.id, 'unauthorized', `${req.m} is open to the daemon's owner only`);
321
314
 
322
315
  return true;
@@ -379,7 +372,7 @@ export class DaemonConnection {
379
372
  return;
380
373
  }
381
374
 
382
- if (OWNER_METHODS.has(req.m)) {
375
+ if (isOwnerOnlyMethod(req.m)) {
383
376
  this.sendErr(req.id, 'unauthorized', `${req.m} is open to the daemon's owner only`);
384
377
 
385
378
  return;
@@ -548,6 +541,12 @@ export class DaemonConnection {
548
541
  private async applyRequest(req: RequestMsg, ctx: DaemonContext): Promise<void> {
549
542
  await this.helloAnswered;
550
543
 
544
+ if (!isRequestMethod(req.m)) {
545
+ this.sendErr(req.id, 'unknown_method', `unknown method '${req.m}'`);
546
+
547
+ return;
548
+ }
549
+
551
550
  switch (req.m) {
552
551
  case 'daemon.ping': {
553
552
  this.sendOk(req.id, {});
@@ -908,7 +907,10 @@ export class DaemonConnection {
908
907
 
909
908
  return;
910
909
  }
911
- default: {
910
+
911
+ // The handshake is answered before a request is admitted, so it never
912
+ // runs as one.
913
+ case 'daemon.hello': {
912
914
  this.sendErr(req.id, 'unknown_method', `unknown method '${req.m}'`);
913
915
  }
914
916
  }
@@ -1779,6 +1781,17 @@ export class DaemonConnection {
1779
1781
  }
1780
1782
  }
1781
1783
 
1784
+ // Whether a request method is open to the daemon's owner alone. A method
1785
+ // the protocol does not define is not: it runs as no request and is
1786
+ // answered as unknown.
1787
+ function isOwnerOnlyMethod(method: string): boolean {
1788
+ return isRequestMethod(method) && REQUEST_ACCESS_CLASSES[method] === 'owner';
1789
+ }
1790
+
1791
+ function isRequestMethod(method: string): method is RequestMethod {
1792
+ return Object.hasOwn(REQUEST_ACCESS_CLASSES, method);
1793
+ }
1794
+
1782
1795
  // The token of a handshake's `auth: { scheme: "bearer", token }`, or null
1783
1796
  // when its params hold none.
1784
1797
  function findBearerToken(p: Readonly<Record<string, unknown>> | undefined): string | null {
@@ -48,6 +48,8 @@ import { buildTargetAccess } from './build-target-access';
48
48
  import { buildTargetForbiddenError } from './build-target-forbidden-error';
49
49
  import { buildTargetList } from './build-target-list';
50
50
  import { claimDaemonLock } from './claim-daemon-lock';
51
+ import { createNonBlockingLog } from './create-non-blocking-log';
52
+ import type { NonBlockingLog } from './create-non-blocking-log';
51
53
  import { DaemonConnection } from './daemon-connection';
52
54
  import type {
53
55
  DaemonContext,
@@ -69,7 +71,6 @@ import { loadListenerTokens } from './load-listener-tokens';
69
71
  import { loadTranscriptPage } from './load-transcript-page';
70
72
  import { makeHookRunner } from './make-hook-runner';
71
73
  import type { HookScope } from './make-hook-runner';
72
- import { makeNonBlockingLog } from './make-non-blocking-log';
73
74
  import { materializeWorkspace } from './materialize-workspace';
74
75
  import { mintMessageID } from './mint-message-id';
75
76
  import { mintSessionID } from './mint-session-id';
@@ -253,6 +254,10 @@ const MAX_REFUSAL_WINDOWS = 1024;
253
254
  // Where the TCP listener logs when the daemon is given no log.
254
255
  const STDERR_FD = 2;
255
256
 
257
+ // How long a stopping daemon waits for the TCP listener's log to write the
258
+ // lines it still holds, so an unread stderr delays the exit no longer.
259
+ const LOG_DRAIN_TIMEOUT_MS = 1000;
260
+
256
261
  // How long startup waits for a daemon that is shutting down to release the
257
262
  // state lock before refusing to start.
258
263
  const LOCK_WAIT_MS = 2000;
@@ -2082,8 +2087,15 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
2082
2087
  // refuses the start before any client can connect, and releases what the
2083
2088
  // daemon holds, the lock included.
2084
2089
  let tcpListener: TCPListener | null = null;
2090
+ let listenerLog: NonBlockingLog | null = null;
2085
2091
 
2086
2092
  if (opts.listen !== undefined && listenTokens !== null) {
2093
+ // A given log writes as it is called, so it has nothing to drain.
2094
+ listenerLog =
2095
+ opts.log === undefined
2096
+ ? createNonBlockingLog(STDERR_FD)
2097
+ : { log: opts.log, drain: () => Promise.resolve() };
2098
+
2087
2099
  try {
2088
2100
  tcpListener = startTCPListener({
2089
2101
  host: opts.listen.host,
@@ -2099,7 +2111,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
2099
2111
  return connection;
2100
2112
  },
2101
2113
  closeConnection: detachConnection,
2102
- log: opts.log ?? makeNonBlockingLog(STDERR_FD),
2114
+ log: listenerLog.log,
2103
2115
  now: opts.listen.now ?? Date.now,
2104
2116
  refusalLogIntervalMs: opts.listen.refusalLogIntervalMs ?? REFUSAL_LOG_INTERVAL_MS,
2105
2117
  maxRefusalWindows: opts.listen.maxRefusalWindows ?? MAX_REFUSAL_WINDOWS,
@@ -2167,6 +2179,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
2167
2179
  server.stop(true);
2168
2180
 
2169
2181
  await releaseResources();
2182
+
2183
+ // The stopped listener logs no more lines, so this writes the ones the
2184
+ // log still holds, waiting no longer than the timeout when nothing reads
2185
+ // stderr.
2186
+ await listenerLog?.drain(LOG_DRAIN_TIMEOUT_MS);
2170
2187
  };
2171
2188
 
2172
2189
  writeDaemonRecord(recordPath, {
@@ -0,0 +1,54 @@
1
+ import type { RequestMethod } from '../protocol/parse-request-params';
2
+
3
+ /**
4
+ * Who may make a request: `owner` is the daemon's owner alone, and
5
+ * `principal` is also open to a principal, which runs it through the
6
+ * context scoped to that principal.
7
+ */
8
+ export type RequestAccessClass = 'owner' | 'principal';
9
+
10
+ /**
11
+ * The access class of every request method, checked when a request is
12
+ * admitted and before it runs. The type takes every method the protocol
13
+ * defines, so a method without a class fails typecheck. Owner-class
14
+ * methods act on the whole daemon, or on the credentials a session's host
15
+ * may use.
16
+ */
17
+ export const REQUEST_ACCESS_CLASSES: Readonly<Record<RequestMethod, RequestAccessClass>> = {
18
+ 'daemon.hello': 'principal',
19
+ 'daemon.ping': 'principal',
20
+ 'daemon.quit': 'owner',
21
+ 'session.list': 'principal',
22
+ 'dirs.list': 'principal',
23
+ 'agents.list': 'principal',
24
+ 'sources.list': 'principal',
25
+ 'sources.interpret': 'principal',
26
+ 'git.probe': 'principal',
27
+ 'fleet.list': 'principal',
28
+ 'fleet.restore': 'owner',
29
+ 'session.spawn': 'principal',
30
+ 'session.kill': 'principal',
31
+ 'session.ack': 'principal',
32
+ 'session.forget': 'principal',
33
+ 'session.resumeCommand': 'principal',
34
+ 'session.auth.revoke': 'owner',
35
+ 'session.auth.rebind': 'owner',
36
+ 'session.update': 'principal',
37
+ 'session.attach': 'principal',
38
+ 'session.detach': 'principal',
39
+ 'session.input': 'principal',
40
+ 'session.submit': 'principal',
41
+ 'session.resize': 'principal',
42
+ 'session.screen': 'principal',
43
+ 'session.eject': 'principal',
44
+ 'session.adopt': 'principal',
45
+ 'permission.respond': 'principal',
46
+ 'session.get': 'principal',
47
+ 'session.read': 'principal',
48
+ 'events.read': 'principal',
49
+ 'session.message': 'principal',
50
+ 'session.tap': 'principal',
51
+ 'message.get': 'principal',
52
+ 'report.get': 'principal',
53
+ 'message.ack': 'principal',
54
+ };