This is an automated email from the ASF dual-hosted git repository.

jason810496 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 372025f2549 Send TS SDK logs to stderr on socket disconnect (#69302)
372025f2549 is described below

commit 372025f2549782f759c0eb8a1723bf732f70d1ba
Author: Guan-Ming Chiu <[email protected]>
AuthorDate: Sun Jul 19 15:30:12 2026 +0800

    Send TS SDK logs to stderr on socket disconnect (#69302)
    
    * Send TypeScript SDK logs to stderr when the log socket disconnects
    
    Co-authored-by: Jason(Zhe-You) Liu 
<[email protected]>
    
    * Harden TS SDK log channel shutdown
    
    * Unref log channel close flush timer so it cannot delay process exit
    
    ---------
    
    Co-authored-by: Jason(Zhe-You) Liu 
<[email protected]>
---
 ts-sdk/src/coordinator/log-channel.ts        |  61 +++++++++++---
 ts-sdk/tests/coordinator/log-channel.test.ts | 121 ++++++++++++++++++++++++++-
 2 files changed, 167 insertions(+), 15 deletions(-)

diff --git a/ts-sdk/src/coordinator/log-channel.ts 
b/ts-sdk/src/coordinator/log-channel.ts
index e64737e3b67..dd3d78ea509 100644
--- a/ts-sdk/src/coordinator/log-channel.ts
+++ b/ts-sdk/src/coordinator/log-channel.ts
@@ -47,31 +47,48 @@ export interface LogRecord {
 
 const DEFAULT_LOGGER_NAME = "ts-sdk";
 
+const CLOSE_FLUSH_TIMEOUT_MS = 3_000;
+
+interface LogChannelState {
+  sock: Socket;
+  connected: boolean;
+  closed: boolean;
+}
+
 export class LogChannel {
-  private readonly sock: Socket;
+  private readonly shared: LogChannelState;
   private readonly name: string;
   private readonly isRoot: boolean;
 
-  private constructor(sock: Socket, name: string, isRoot: boolean) {
-    this.sock = sock;
+  private constructor(shared: LogChannelState, name: string, isRoot: boolean) {
+    this.shared = shared;
     this.name = name;
     this.isRoot = isRoot;
-    if (isRoot) {
-      sock.on("error", (err) => {
-        process.stderr.write(`[${this.name}] log socket error: 
${err.message}\n`);
-      });
-    }
   }
 
   static async connect(addr: string, name: string = DEFAULT_LOGGER_NAME): 
Promise<LogChannel> {
-    return new LogChannel(await connectTcp(addr), name, true);
+    const shared: LogChannelState = {
+      sock: await connectTcp(addr),
+      connected: true,
+      closed: false,
+    };
+    shared.sock.on("error", (err) => {
+      shared.connected = false;
+      process.stderr.write(`[${name}] log socket error: ${err.message}\n`);
+    });
+    shared.sock.on("close", () => {
+      if (shared.closed || !shared.connected) return;
+      shared.connected = false;
+      process.stderr.write(`[${name}] log socket closed unexpectedly; further 
logs go to stderr\n`);
+    });
+    return new LogChannel(shared, name, true);
   }
 
   /** Create a sibling logger that shares the underlying socket but
    *  carries a hierarchical name (`parent.suffix`). Only the root
    *  owns the socket — children's `close()` is a no-op. */
   child(suffix: string): LogChannel {
-    return new LogChannel(this.sock, `${this.name}.${suffix}`, false);
+    return new LogChannel(this.shared, `${this.name}.${suffix}`, false);
   }
 
   /** Name reported in the `logger` field of every record this
@@ -87,7 +104,7 @@ export class LogChannel {
     },
   ): void {
     // Drop late records after the log socket has closed.
-    if (this.sock.writableEnded) return;
+    if (this.shared.closed) return;
     // Prepend the logger name to the event message so it surfaces in
     // the Airflow UI task log view, which renders the message text but
     // hides the `logger` JSON field. The field is still emitted for
@@ -99,7 +116,12 @@ export class LogChannel {
       event: `[${this.name}] ${record.event}`,
       timestamp: record.timestamp ?? new Date().toISOString(),
     });
-    this.sock.write(Buffer.from(line + "\n", "utf8"));
+    const payload = line + "\n";
+    if (!this.shared.connected || !this.shared.sock.writable) {
+      process.stderr.write(payload);
+      return;
+    }
+    this.shared.sock.write(Buffer.from(payload, "utf8"));
   }
 
   debug(event: string, args: Record<string, unknown> = {}): void {
@@ -120,8 +142,21 @@ export class LogChannel {
 
   async close(): Promise<void> {
     if (!this.isRoot) return;
+    this.shared.closed = true;
+    if (!this.shared.connected) {
+      this.shared.sock.destroy();
+      return;
+    }
     return new Promise((resolve) => {
-      this.sock.end(() => resolve());
+      const timer = setTimeout(() => {
+        this.shared.sock.destroy();
+        resolve();
+      }, CLOSE_FLUSH_TIMEOUT_MS);
+      timer.unref();
+      this.shared.sock.end(() => {
+        clearTimeout(timer);
+        resolve();
+      });
     });
   }
 }
diff --git a/ts-sdk/tests/coordinator/log-channel.test.ts 
b/ts-sdk/tests/coordinator/log-channel.test.ts
index 2b8a79579dc..ad85c487ce8 100644
--- a/ts-sdk/tests/coordinator/log-channel.test.ts
+++ b/ts-sdk/tests/coordinator/log-channel.test.ts
@@ -135,7 +135,7 @@ describe("LogChannel", () => {
     const write = vi.spyOn(process.stderr, "write").mockImplementation(() => 
true);
     const root = await LogChannel.connect(`127.0.0.1:${fx.port}`);
     root.child("child");
-    const sock = (root as unknown as { sock: net.Socket }).sock;
+    const sock = (root as unknown as { shared: { sock: net.Socket } 
}).shared.sock;
 
     try {
       sock.emit("error", new Error("boom"));
@@ -163,10 +163,61 @@ describe("LogChannel", () => {
     expect(records[0]).toMatchObject({ event: "[ts-sdk] before close" });
   });
 
+  it("writes only the error message when error and close fire together", async 
() => {
+    const write = vi.spyOn(process.stderr, "write").mockImplementation(() => 
true);
+    const root = await LogChannel.connect(`127.0.0.1:${fx.port}`);
+    const sock = (root as unknown as { shared: { sock: net.Socket } 
}).shared.sock;
+
+    try {
+      sock.emit("error", Object.assign(new Error("write EPIPE"), { code: 
"EPIPE" }));
+      sock.destroy();
+      await fx.sockClosed;
+      expect(write).toHaveBeenCalledTimes(1);
+      expect(write.mock.calls[0]?.[0]).toBe("[ts-sdk] log socket error: write 
EPIPE\n");
+    } finally {
+      write.mockRestore();
+      await root.close();
+    }
+  });
+
+  it("falls back to stderr when the socket is no longer writable", async () => 
{
+    const write = vi.spyOn(process.stderr, "write").mockImplementation(() => 
true);
+    const root = await LogChannel.connect(`127.0.0.1:${fx.port}`);
+    const sock = (root as unknown as { shared: { sock: net.Socket } 
}).shared.sock;
+
+    try {
+      sock.end();
+      root.info("peer died");
+      const line = write.mock.calls.find((c) => String(c[0]).includes("peer 
died"))?.[0];
+      expect(String(line)).toContain('"event":"[ts-sdk] peer died"');
+    } finally {
+      write.mockRestore();
+      await root.close();
+      await fx.sockClosed;
+    }
+  });
+
+  it("close() resolves via timeout when the flush never completes", async () 
=> {
+    const root = await LogChannel.connect(`127.0.0.1:${fx.port}`);
+    const sock = (root as unknown as { shared: { sock: net.Socket } 
}).shared.sock;
+    vi.spyOn(sock, "end").mockImplementation(() => sock);
+    const destroy = vi.spyOn(sock, "destroy");
+
+    vi.useFakeTimers();
+    try {
+      const closed = root.close();
+      await vi.advanceTimersByTimeAsync(3_000);
+      await closed;
+      expect(destroy).toHaveBeenCalled();
+    } finally {
+      vi.useRealTimers();
+    }
+  });
+
   it("reports close-time socket errors", async () => {
     const write = vi.spyOn(process.stderr, "write").mockImplementation(() => 
true);
     const root = await LogChannel.connect(`127.0.0.1:${fx.port}`);
-    const sock = (root as unknown as { sock: net.Socket }).sock;
+    const sock = (root as unknown as { shared: { sock: net.Socket } 
}).shared.sock;
 
     try {
       sock.emit("error", Object.assign(new Error("write EPIPE"), { code: 
"EPIPE" }));
@@ -178,4 +229,70 @@ describe("LogChannel", () => {
       await fx.sockClosed;
     }
   });
+
+  it("warns once and falls back to stderr after an unexpected disconnect", 
async () => {
+    const serverSocks: net.Socket[] = [];
+    const received: Buffer[] = [];
+    const server = net.createServer((sock) => {
+      serverSocks.push(sock);
+      sock.on("data", (chunk) => received.push(chunk));
+    });
+    await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", 
resolve));
+    const port = (server.address() as net.AddressInfo).port;
+
+    const write = vi.spyOn(process.stderr, "write").mockImplementation(() => 
true);
+    const root = await LogChannel.connect(`127.0.0.1:${port}`);
+    const child = root.child("comm");
+    const shared = (root as unknown as { shared: { connected: boolean } 
}).shared;
+    try {
+      root.info("before disconnect");
+      await vi.waitFor(() => expect(readRecords(received)).toHaveLength(1));
+
+      serverSocks[0]!.destroy();
+      await vi.waitFor(() => expect(shared.connected).toBe(false));
+      expect(write).toHaveBeenCalledWith(
+        "[ts-sdk] log socket closed unexpectedly; further logs go to stderr\n",
+      );
+
+      root.info("while disconnected");
+      child.debug("child while disconnected");
+      const stderrRecords = write.mock.calls
+        .map((call) => String(call[0]))
+        .filter((line) => line.startsWith("{"))
+        .map((line) => JSON.parse(line) as Record<string, unknown>);
+      expect(stderrRecords.map((r) => r["event"])).toEqual([
+        "[ts-sdk] while disconnected",
+        "[ts-sdk.comm] child while disconnected",
+      ]);
+      expect(readRecords(received)).toHaveLength(1);
+    } finally {
+      write.mockRestore();
+      await root.close();
+      server.close();
+    }
+  });
+
+  it("close() while disconnected resolves and drops later records", async () 
=> {
+    const serverSocks: net.Socket[] = [];
+    const server = net.createServer((sock) => serverSocks.push(sock));
+    await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", 
resolve));
+    const port = (server.address() as net.AddressInfo).port;
+
+    const write = vi.spyOn(process.stderr, "write").mockImplementation(() => 
true);
+    const root = await LogChannel.connect(`127.0.0.1:${port}`);
+    try {
+      await vi.waitFor(() => expect(serverSocks).toHaveLength(1));
+      serverSocks[0]!.destroy();
+      const shared = (root as unknown as { shared: { connected: boolean } 
}).shared;
+      await vi.waitFor(() => expect(shared.connected).toBe(false));
+
+      await root.close();
+      write.mockClear();
+      root.info("dropped after close");
+      expect(write).not.toHaveBeenCalled();
+    } finally {
+      write.mockRestore();
+      server.close();
+    }
+  });
 });

Reply via email to