From 5a631e1ee9faf049c2bcc194f5240f2a30d21eb4 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 29 Apr 2026 17:47:02 -0700 Subject: [PATCH] fix(docs): bound i18n codex prompt cleanup --- scripts/docs-i18n/codex_command_unix.go | 25 ++++++++++++++ scripts/docs-i18n/codex_command_windows.go | 9 +++++ scripts/docs-i18n/translator.go | 25 +++++++++++--- scripts/docs-i18n/translator_test.go | 39 ++++++++++++++++++++++ 4 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 scripts/docs-i18n/codex_command_unix.go create mode 100644 scripts/docs-i18n/codex_command_windows.go diff --git a/scripts/docs-i18n/codex_command_unix.go b/scripts/docs-i18n/codex_command_unix.go new file mode 100644 index 00000000000..7f669b6e0bb --- /dev/null +++ b/scripts/docs-i18n/codex_command_unix.go @@ -0,0 +1,25 @@ +//go:build !windows + +package main + +import ( + "errors" + "os" + "os/exec" + "syscall" +) + +func configureCodexPromptCommand(command *exec.Cmd) { + command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + command.Cancel = func() error { + if command.Process == nil { + return os.ErrProcessDone + } + err := syscall.Kill(-command.Process.Pid, syscall.SIGKILL) + if errors.Is(err, syscall.ESRCH) { + return os.ErrProcessDone + } + return err + } + command.WaitDelay = docsI18nCommandWaitDelay() +} diff --git a/scripts/docs-i18n/codex_command_windows.go b/scripts/docs-i18n/codex_command_windows.go new file mode 100644 index 00000000000..ffe638ae4d4 --- /dev/null +++ b/scripts/docs-i18n/codex_command_windows.go @@ -0,0 +1,9 @@ +//go:build windows + +package main + +import "os/exec" + +func configureCodexPromptCommand(command *exec.Cmd) { + command.WaitDelay = docsI18nCommandWaitDelay() +} diff --git a/scripts/docs-i18n/translator.go b/scripts/docs-i18n/translator.go index a65226cc23b..9e829493531 100644 --- a/scripts/docs-i18n/translator.go +++ b/scripts/docs-i18n/translator.go @@ -14,11 +14,13 @@ import ( ) const ( - translateMaxAttempts = 3 - translateBaseDelay = 15 * time.Second - defaultPromptTimeout = 2 * time.Minute - envDocsI18nPromptTimeout = "OPENCLAW_DOCS_I18N_PROMPT_TIMEOUT" - envDocsI18nCodexExecutable = "OPENCLAW_DOCS_I18N_CODEX_EXECUTABLE" + translateMaxAttempts = 3 + translateBaseDelay = 15 * time.Second + defaultPromptTimeout = 2 * time.Minute + defaultCommandWaitDelay = 15 * time.Second + envDocsI18nPromptTimeout = "OPENCLAW_DOCS_I18N_PROMPT_TIMEOUT" + envDocsI18nCommandWaitDelay = "OPENCLAW_DOCS_I18N_COMMAND_WAIT_DELAY" + envDocsI18nCodexExecutable = "OPENCLAW_DOCS_I18N_CODEX_EXECUTABLE" ) var errEmptyTranslation = errors.New("empty translation") @@ -214,6 +216,7 @@ func runCodexExecPrompt(ctx context.Context, req codexPromptRequest) (string, er "-", } command := exec.CommandContext(ctx, docsCodexExecutable(), args...) + configureCodexPromptCommand(command) command.Stdin = strings.NewReader(buildCodexTranslationPrompt(req.SystemPrompt, req.Message)) command.Env = append(os.Environ(), "CODEX_HOME="+codexHome) var stdout bytes.Buffer @@ -327,3 +330,15 @@ func docsI18nPromptTimeout() time.Duration { } return parsed } + +func docsI18nCommandWaitDelay() time.Duration { + value := strings.TrimSpace(os.Getenv(envDocsI18nCommandWaitDelay)) + if value == "" { + return defaultCommandWaitDelay + } + parsed, err := time.ParseDuration(value) + if err != nil || parsed <= 0 { + return defaultCommandWaitDelay + } + return parsed +} diff --git a/scripts/docs-i18n/translator_test.go b/scripts/docs-i18n/translator_test.go index 307110ce9ed..fcd3d1d8030 100644 --- a/scripts/docs-i18n/translator_test.go +++ b/scripts/docs-i18n/translator_test.go @@ -56,6 +56,14 @@ func TestDocsI18nPromptTimeoutUsesEnvOverride(t *testing.T) { } } +func TestDocsI18nCommandWaitDelayUsesEnvOverride(t *testing.T) { + t.Setenv(envDocsI18nCommandWaitDelay, "50ms") + + if got := docsI18nCommandWaitDelay(); got != 50*time.Millisecond { + t.Fatalf("expected 50ms wait delay, got %s", got) + } +} + func TestIsRetryableTranslateErrorRejectsDeadlineExceeded(t *testing.T) { t.Parallel() @@ -235,6 +243,37 @@ printf 'translated from codex\n' > "$out" } } +func TestRunCodexExecPromptDoesNotHangOnInheritedPipesAfterTimeout(t *testing.T) { + dir := t.TempDir() + fakeCodex := filepath.Join(dir, "codex") + if err := os.WriteFile(fakeCodex, []byte(`#!/bin/sh +set -eu +(sleep 10) & +sleep 10 +`), 0o755); err != nil { + t.Fatalf("write fake codex: %v", err) + } + t.Setenv(envDocsI18nCodexExecutable, fakeCodex) + t.Setenv(envDocsI18nCommandWaitDelay, "20ms") + t.Setenv("OPENAI_API_KEY", "test-openai-key") + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + started := time.Now() + _, err := runCodexExecPrompt(ctx, codexPromptRequest{ + SystemPrompt: "Translate.", + Message: "Hello", + Model: "gpt-5.5", + Thinking: "high", + }) + if err == nil { + t.Fatal("expected timeout error") + } + if elapsed := time.Since(started); elapsed > 2*time.Second { + t.Fatalf("expected bounded timeout, took %s", elapsed) + } +} + func TestPreviewCommandOutputFlattensAndTruncates(t *testing.T) { input := "line one\n\nline two\tline three " + strings.Repeat("x", 600) preview := previewCommandOutput(input, "")