From c6615faa49aa4252d6a437dc3fe042a196356743 Mon Sep 17 00:00:00 2001 From: m1saka Date: Sat, 17 Oct 2026 00:52:20 +0800 Subject: [PATCH] fix: extend proxy timeout for Office rendering --- OPERATIONS.md | 4 + internal/api/runtime.go | 23 +++- internal/api/runtime_office_test.go | 167 ++++++++++++++++++++++++++++ 3 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 internal/api/runtime_office_test.go diff --git a/OPERATIONS.md b/OPERATIONS.md index 493026c..13e55d5 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -118,6 +118,10 @@ bash scripts/check-trial.sh `scripts/test-panel.mjs` 必须指向独立测试部署,通过 `TEST_PANEL_URL`、`TEST_USERNAME`、`TEST_PASSWORD` 指定访问参数;`TEST_UPGRADE=1` 包含同镜像替换升级。运行前需由管理员通过 Git 发布构建准备已验证且准入的本地 runtime 镜像;可用 `TEST_FROM_VERSION` / `TEST_TO_VERSION` 选择源和目标版本。脚本不提交构建,只复用这些镜像,会创建及删除测试实例,删除时一并清理实例数据。不要对生产账号运行。`scripts/smoke-image.sh ` 和 `scripts/test-mobile-image.mjs ` 仅测试已有镜像的运行行为,不读取 main 的构建源;仅在隔离测试环境使用。旧构建脚本和构建后创建意图的 live 测试已删除。 +## Office 文档预览超时 + +Office 文档预览的运行时接口 `/api/officeToPdf/render`(面板路径为 `/dsh/:id/api/officeToPdf/render`)使用独立代理连接池,响应头等待上限为120秒;其它运行时请求及认证仍保持原有20秒上限。此设置避免转换尚未完成就被面板提前截断为502,不修改DSH转换器自身的超时(当前镜像默认60秒),也不保证转换一定成功。浏览器取消请求仍会传递至上游。若面板前还有Nginx等代理,其等待上限也应允许转换完成。此修复只需更新面板,无需为此重建运行镜像。 + ## 手动备份空间管理 实例备份包和历史完整工作区快照必须由用户显式删除,不执行自动清理。快照列表与删除接口见 API.md。删除快照会降低工作区逻辑用量;加密包在独立 backups 目录,不计入当前工作区软配额。快照大小不等于物理磁盘占用,硬链接按路径重复计数;`size_error` 表示无法完整扫描,应显示未知而非零。 diff --git a/internal/api/runtime.go b/internal/api/runtime.go index a3a0809..79fe9f9 100644 --- a/internal/api/runtime.go +++ b/internal/api/runtime.go @@ -535,6 +535,27 @@ func (a *API) runtimeOriginAllowed(r *http.Request) bool { var runtimeTransport = &http.Transport{Proxy: nil, DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext, ResponseHeaderTimeout: 20 * time.Second, IdleConnTimeout: 90 * time.Second} +// Office rendering can take longer than the normal API header deadline (the +// bundled converter defaults to 60 seconds). Reuse a separate connection pool +// without extending authentication or unrelated runtime requests. +var runtimeOfficeTransport = func() *http.Transport { + t := runtimeTransport.Clone() + t.ResponseHeaderTimeout = 120 * time.Second + return t +}() + +type runtimeProxyTransport struct { + normal, office http.RoundTripper +} + +func (t runtimeProxyTransport) RoundTrip(r *http.Request) (*http.Response, error) { + // ReverseProxy has already removed the instance prefix here. + if r.URL.Path == "/api/officeToPdf/render" { + return t.office.RoundTrip(r) + } + return t.normal.RoundTrip(r) +} + type runtimeAuthKey struct { api *API id string @@ -1016,7 +1037,7 @@ func withoutRuntimeToken(rawQuery string) string { func runtimeReverseProxy(target *url.URL, prefix, host, cookies string) *httputil.ReverseProxy { return &httputil.ReverseProxy{ - Transport: runtimeTransport, FlushInterval: -1, ErrorLog: log.New(io.Discard, "", 0), + Transport: runtimeProxyTransport{normal: runtimeTransport, office: runtimeOfficeTransport}, FlushInterval: -1, ErrorLog: log.New(io.Discard, "", 0), Rewrite: func(p *httputil.ProxyRequest) { p.SetURL(target) p.Out.Host = host diff --git a/internal/api/runtime_office_test.go b/internal/api/runtime_office_test.go new file mode 100644 index 0000000..02f4df5 --- /dev/null +++ b/internal/api/runtime_office_test.go @@ -0,0 +1,167 @@ +package api + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "net/http/httputil" + "net/url" + "strings" + "sync/atomic" + "testing" + "time" +) + +func officeTestProxy(t *testing.T, upstream string, officeTimeout time.Duration) *httputil.ReverseProxy { + t.Helper() + target, err := url.Parse(upstream) + if err != nil { + t.Fatal(err) + } + normal := runtimeTransport.Clone() + office := runtimeOfficeTransport.Clone() + normal.ResponseHeaderTimeout = 30 * time.Millisecond + office.ResponseHeaderTimeout = officeTimeout + t.Cleanup(normal.CloseIdleConnections) + t.Cleanup(office.CloseIdleConnections) + proxy := runtimeReverseProxy(target, "/dsh/office-test", "panel.test", "runtime=secret") + proxy.Transport = runtimeProxyTransport{normal: normal, office: office} + return proxy +} + +func TestRuntimeOfficeTransportDefaults(t *testing.T) { + if got := runtimeTransport.ResponseHeaderTimeout; got != 20*time.Second { + t.Errorf("normal timeout = %s, want 20s", got) + } + if got := runtimeOfficeTransport.ResponseHeaderTimeout; got != 120*time.Second { + t.Errorf("office timeout = %s, want 120s", got) + } + if runtimeTransport == runtimeOfficeTransport { + t.Fatal("office transport must be separate") + } + target, _ := url.Parse("http://panel.test") + proxy := runtimeReverseProxy(target, "/dsh/office-test", "panel.test", "") + transport, ok := proxy.Transport.(runtimeProxyTransport) + if !ok || transport.normal != runtimeTransport || transport.office != runtimeOfficeTransport { + t.Fatalf("unexpected default transport: %#v", proxy.Transport) + } +} + +func TestRuntimeOfficeProxyRouting(t *testing.T) { + for _, path := range []string{ + "/api/officeToPdf/render", + "/api/status", + "/api/officeToPdf/render/", + "/api/officeToPdf/render/child", + "/api/officeToPdf/renderOther", + "/api/officetopdf/render", + "/api/officeToPdf/status", + } { + t.Run(path, func(t *testing.T) { + const body = "document payload" + const query = "file=report%20one.docx&mode=pdf&mode=preview" + var calls atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + data, err := io.ReadAll(r.Body) + if err != nil || string(data) != body || r.Method != http.MethodPost { + t.Errorf("upstream method/body = %s %q, error %v", r.Method, data, err) + } + if r.URL.Path != path || r.URL.RawQuery != query { + t.Errorf("upstream URL = %s", r.URL) + } + if r.Host != "panel.test" || r.Header.Get("X-Forwarded-Prefix") != "/dsh/office-test" || r.Header.Get("Cookie") != "runtime=secret" { + t.Errorf("upstream host/headers = %s %v", r.Host, r.Header) + } + time.Sleep(100 * time.Millisecond) + w.Header().Set("Content-Type", "application/pdf") + _, _ = io.WriteString(w, "rendered PDF") + })) + defer upstream.Close() + proxy := officeTestProxy(t, upstream.URL, 500*time.Millisecond) + req := httptest.NewRequest(http.MethodPost, "/dsh/office-test"+path+"?"+query+"&token=removed", strings.NewReader(body)) + req.Header.Set("Cookie", "panel=private") + originalURL := req.URL.String() + w := httptest.NewRecorder() + proxy.ServeHTTP(w, req) + want := http.StatusBadGateway + if path == "/api/officeToPdf/render" { + want = http.StatusOK + if w.Body.String() != "rendered PDF" { + t.Errorf("response body = %q", w.Body.String()) + } + } + if w.Code != want { + t.Errorf("status = %d, want %d: %s", w.Code, want, w.Body.String()) + } + if req.URL.String() != originalURL || req.Header.Get("Cookie") != "panel=private" { + t.Error("proxy mutated incoming URL or cookies") + } + upstream.Close() + if got := calls.Load(); got != 1 { + t.Errorf("mutating request sent %d times, want exactly once", got) + } + }) + } +} + +func TestRuntimeOfficeProxyFiniteTimeoutAndCancellation(t *testing.T) { + for _, name := range []string{"header timeout", "caller cancellation"} { + t.Run(name, func(t *testing.T) { + started := make(chan struct{}, 2) + canceled := make(chan struct{}, 2) + var calls atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + _, _ = io.Copy(io.Discard, r.Body) + started <- struct{}{} + select { + case <-r.Context().Done(): + canceled <- struct{}{} + case <-time.After(2 * time.Second): + } + })) + defer upstream.Close() + officeTimeout := 100 * time.Millisecond + if name == "caller cancellation" { + officeTimeout = 5 * time.Second + } + proxy := officeTestProxy(t, upstream.URL, officeTimeout) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + req := httptest.NewRequest(http.MethodPost, "/dsh/office-test/api/officeToPdf/render?mode=pdf", strings.NewReader("document")).WithContext(ctx) + w := httptest.NewRecorder() + done := make(chan struct{}) + go func() { + proxy.ServeHTTP(w, req) + close(done) + }() + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("request never reached upstream") + } + if name == "caller cancellation" { + cancel() + } + select { + case <-done: + case <-time.After(500 * time.Millisecond): + t.Fatal("proxy did not promptly finish") + } + if w.Code != http.StatusBadGateway { + t.Errorf("status = %d, want 502", w.Code) + } + select { + case <-canceled: + case <-time.After(500 * time.Millisecond): + t.Fatal("upstream context was not canceled") + } + upstream.Close() + if got := calls.Load(); got != 1 { + t.Errorf("mutating request sent %d times, want exactly once", got) + } + }) + } +}