v0.8.8 : envoi de fichiers jusqu'a 1 Go, en morceaux

La limite de 24 Mo n'etait pas arbitraire : le fichier partait dans un
seul corps JSON en base64, tenu entier en memoire par le navigateur puis
par le serveur. Relever la constante seule aurait demande ~1,4 Go de RAM
de chaque cote pour un fichier de 1 Go.

Le transfert est donc decoupe en tranches de 8 Mo. Le navigateur lit une
tranche a la fois (Blob.slice), le serveur la decode, l'ecrit dans un
.part et l'oublie ; seul le descripteur reste ouvert. Mesure sur 120 Mo :
la RAM du process n'a pas bouge (95 -> 96 Mo).

uploadMaxBytes (1 Go) borne desormais le DISQUE ; c'est uploadChunkMax
(8 Mo) qui borne la memoire, et lui seul.

Une session interrompue est balayee au bout de 10 min, et les .part
orphelins sont effaces au demarrage du service : les sessions vivent en
memoire, aucun n'est reprenable apres un arret. Un identifiant inconnu
est refuse en 409 plutot que de reprendre un fichier en son milieu.

La vignette affiche l'avancement en pourcentage au-dela d'une tranche.
This commit is contained in:
nathaninline committed 2026-08-10 20:05:01 +02:00
1 parent 6807bad48d
commit e07051e1e3
10 files changed
+325 -83

No files matched your search

+1 -1
View File
@@ -10,7 +10,7 @@ import (
"strings"
)
const Version = "0.8.7"
const Version = "0.8.8"
// Main est le vrai main() du binaire (cmd/ajean ne fait que l'appeler).
func Main() {
+44 -13
View File
@@ -5021,11 +5021,17 @@ async function remotePairCode(){
// Le transfert passe par du base64 dans du JSON (et non du multipart) : c'est la
// seule forme qui traverse le tunnel E2E d'app.ajean.link, donc l'accès distant
// fonctionne sans code particulier.
const ATTACH_MAX = 24*1024*1024; // doit rester aligné sur uploadMaxBytes (web_upload.go)
// Doivent rester alignés sur uploadMaxBytes / uploadChunkMax (web_upload.go).
// Le fichier part par MORCEAUX : ni le navigateur ni le serveur n'en tiennent
// plus d'un à la fois en mémoire, ce qui rend le gigaoctet possible. Envoyer
// 1 Go en un seul corps JSON demanderait ~1,4 Go de base64 de chaque côté.
const ATTACH_MAX = 1024*1024*1024;
const ATTACH_CHUNK = 8*1024*1024;
let ATTACH = [];
let ATTACH_SEQ = 0;
function fmtSize(n){
if(n >= 1024*1024*1024) return (n/(1024*1024*1024)).toFixed(1)+' Go';
if(n >= 1024*1024) return (n/(1024*1024)).toFixed(n >= 10*1024*1024 ? 0 : 1)+' Mo';
if(n >= 1024) return Math.round(n/1024)+' Ko';
return n+' o';
@@ -5158,7 +5164,11 @@ function renderAttach(){
el.appendChild(fileChip(a.name, a.size, {
cls: a.state==='up' ? 'up' : (a.state==='err' ? 'err' : ''),
title: a.error || a.name,
sizeText: a.state==='err' ? 'échec' : fmtSize(a.size),
// Un gros fichier prend du temps : on montre l'avancement plutôt qu'un
// anneau qui tourne sans rien dire. Sous un morceau, il n'y a rien à suivre.
sizeText: a.state==='err' ? 'échec'
: (a.state==='up' && a.size>ATTACH_CHUNK) ? Math.round((a.sent||0)*100/a.size)+' %'
: fmtSize(a.size),
onRemove: ()=>{ ATTACH=ATTACH.filter(o=>o.id!==a.id); renderAttach(); }
}));
}
@@ -5177,22 +5187,43 @@ function readAsBase64(file){
fr.readAsDataURL(file);
});
}
// Envoie UN morceau. Pas de jfetch : son délai de 30 s est calibré pour de
// petits appels et couperait un transfert.
async function sendChunk(payload){
const r=await fetch(API_BASE+'/api/chat/upload',{
method:'POST', headers:authHeaders({'Content-Type':'application/json'}),
body:JSON.stringify(payload)
});
const j=await r.json().catch(()=>({}));
if(!r.ok || !j.ok) throw new Error(j.error||('erreur '+r.status));
return j;
}
// Le dépôt n'a lieu qu'à l'ENVOI du message, jamais à l'ajout : tant qu'on n'a
// pas cliqué, rien ne doit atterrir dans le dossier de travail de l'IA. Retirer
// un fichier de la liste ne laisse donc aucune trace sur le disque.
//
// Découpage en morceaux de 8 Mo : le navigateur ne lit qu'une tranche à la fois
// (Blob.slice), le serveur l'écrit et l'oublie. Un fichier d'un gigaoctet passe
// donc sans que personne ne le tienne entier en mémoire — et on sait dire où on
// en est, ce qu'un envoi monolithique ne permettait pas.
async function uploadAttach(a){
a.state='up'; renderAttach();
a.state='up'; a.sent=0; renderAttach();
try{
const data=await readAsBase64(a.file);
// Pas de jfetch ici : son délai de 30 s est calibré pour de petits appels,
// et couperait le transfert d'un fichier de plusieurs Mo.
const r=await fetch(API_BASE+'/api/chat/upload',{
method:'POST', headers:authHeaders({'Content-Type':'application/json'}),
body:JSON.stringify({name:a.name, data:data})
});
const j=await r.json().catch(()=>({}));
if(!r.ok || !j.ok) throw new Error(j.error||('erreur '+r.status));
a.path=j.path; a.state='ok'; a.file=null; // le contenu ne sert plus à rien côté page
let id='';
// `do…while` et non `while` : un fichier plus petit qu'un morceau doit quand
// même faire un tour de boucle, sinon rien ne part.
let off=0;
do{
const end=Math.min(off+ATTACH_CHUNK, a.size);
const data=await readAsBase64(a.file.slice(off, end));
const last=end>=a.size;
const j=await sendChunk({name:a.name, data:data, id:id, more:!last});
if(j.id) id=j.id;
off=end;
a.sent=off; renderAttach();
if(last){ a.path=j.path; }
} while(off<a.size);
a.state='ok'; a.file=null; // le contenu ne sert plus à rien côté page
}catch(e){
a.state='err'; a.error=(e&&e.message)||'échec';
toast('« '+a.name+' » : '+a.error);
+44 -13
View File
@@ -8,11 +8,17 @@
// Le transfert passe par du base64 dans du JSON (et non du multipart) : c'est la
// seule forme qui traverse le tunnel E2E d'app.ajean.link, donc l'accès distant
// fonctionne sans code particulier.
const ATTACH_MAX = 24*1024*1024; // doit rester aligné sur uploadMaxBytes (web_upload.go)
// Doivent rester alignés sur uploadMaxBytes / uploadChunkMax (web_upload.go).
// Le fichier part par MORCEAUX : ni le navigateur ni le serveur n'en tiennent
// plus d'un à la fois en mémoire, ce qui rend le gigaoctet possible. Envoyer
// 1 Go en un seul corps JSON demanderait ~1,4 Go de base64 de chaque côté.
const ATTACH_MAX = 1024*1024*1024;
const ATTACH_CHUNK = 8*1024*1024;
let ATTACH = [];
let ATTACH_SEQ = 0;
function fmtSize(n){
if(n >= 1024*1024*1024) return (n/(1024*1024*1024)).toFixed(1)+' Go';
if(n >= 1024*1024) return (n/(1024*1024)).toFixed(n >= 10*1024*1024 ? 0 : 1)+' Mo';
if(n >= 1024) return Math.round(n/1024)+' Ko';
return n+' o';
@@ -145,7 +151,11 @@ function renderAttach(){
el.appendChild(fileChip(a.name, a.size, {
cls: a.state==='up' ? 'up' : (a.state==='err' ? 'err' : ''),
title: a.error || a.name,
sizeText: a.state==='err' ? 'échec' : fmtSize(a.size),
// Un gros fichier prend du temps : on montre l'avancement plutôt qu'un
// anneau qui tourne sans rien dire. Sous un morceau, il n'y a rien à suivre.
sizeText: a.state==='err' ? 'échec'
: (a.state==='up' && a.size>ATTACH_CHUNK) ? Math.round((a.sent||0)*100/a.size)+' %'
: fmtSize(a.size),
onRemove: ()=>{ ATTACH=ATTACH.filter(o=>o.id!==a.id); renderAttach(); }
}));
}
@@ -164,22 +174,43 @@ function readAsBase64(file){
fr.readAsDataURL(file);
});
}
// Envoie UN morceau. Pas de jfetch : son délai de 30 s est calibré pour de
// petits appels et couperait un transfert.
async function sendChunk(payload){
const r=await fetch(API_BASE+'/api/chat/upload',{
method:'POST', headers:authHeaders({'Content-Type':'application/json'}),
body:JSON.stringify(payload)
});
const j=await r.json().catch(()=>({}));
if(!r.ok || !j.ok) throw new Error(j.error||('erreur '+r.status));
return j;
}
// Le dépôt n'a lieu qu'à l'ENVOI du message, jamais à l'ajout : tant qu'on n'a
// pas cliqué, rien ne doit atterrir dans le dossier de travail de l'IA. Retirer
// un fichier de la liste ne laisse donc aucune trace sur le disque.
//
// Découpage en morceaux de 8 Mo : le navigateur ne lit qu'une tranche à la fois
// (Blob.slice), le serveur l'écrit et l'oublie. Un fichier d'un gigaoctet passe
// donc sans que personne ne le tienne entier en mémoire — et on sait dire où on
// en est, ce qu'un envoi monolithique ne permettait pas.
async function uploadAttach(a){
a.state='up'; renderAttach();
a.state='up'; a.sent=0; renderAttach();
try{
const data=await readAsBase64(a.file);
// Pas de jfetch ici : son délai de 30 s est calibré pour de petits appels,
// et couperait le transfert d'un fichier de plusieurs Mo.
const r=await fetch(API_BASE+'/api/chat/upload',{
method:'POST', headers:authHeaders({'Content-Type':'application/json'}),
body:JSON.stringify({name:a.name, data:data})
});
const j=await r.json().catch(()=>({}));
if(!r.ok || !j.ok) throw new Error(j.error||('erreur '+r.status));
a.path=j.path; a.state='ok'; a.file=null; // le contenu ne sert plus à rien côté page
let id='';
// `do…while` et non `while` : un fichier plus petit qu'un morceau doit quand
// même faire un tour de boucle, sinon rien ne part.
let off=0;
do{
const end=Math.min(off+ATTACH_CHUNK, a.size);
const data=await readAsBase64(a.file.slice(off, end));
const last=end>=a.size;
const j=await sendChunk({name:a.name, data:data, id:id, more:!last});
if(j.id) id=j.id;
off=end;
a.sent=off; renderAttach();
if(last){ a.path=j.path; }
} while(off<a.size);
a.state='ok'; a.file=null; // le contenu ne sert plus à rien côté page
}catch(e){
a.state='err'; a.error=(e&&e.message)||'échec';
toast('« '+a.name+' » : '+a.error);
+3
View File
@@ -92,6 +92,9 @@ func newWebMux() *http.ServeMux {
// Un téléchargement de modèle coupé net (crash, restart du service) laisse un
// .part orphelin non reprenable : on nettoie au démarrage.
cleanStalePartFiles()
// Idem pour un envoi de fichier coupé en plein transfert : les sessions vivent
// en mémoire, aucun .part ne survit utilement à l'arrêt du process.
cleanStaleUploadParts()
// Idem pour une installation de moteur : elle meurt avec le process. On
// recharge son état pour l'annoncer « interrompue » au lieu de n'afficher
// plus rien du tout.
+153 -24
View File
@@ -19,13 +19,22 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"time"
"unicode"
)
// uploadMaxBytes borne la taille d'UN fichier. Tout le chemin (corps JSON, E2E,
// écriture) passe par la mémoire : au-delà, un envoi maladroit ferait gonfler le
// process de plusieurs centaines de Mo. 24 Mo décodés ≈ 32 Mo de base64.
const uploadMaxBytes = 24 << 20
const (
// uploadMaxBytes borne la taille d'UN fichier. Le fichier n'est jamais tenu en
// mémoire (voir upSession) : cette limite protège le disque, pas la RAM.
uploadMaxBytes = 1 << 30 // 1 Go
// uploadChunkMax borne UN morceau, et c'est LUI qui décide de la mémoire du
// process : un morceau est reçu en JSON, donc entièrement en RAM, puis décodé.
// 8 Mo décodés ≈ 11 Mo de base64. C'est aussi la taille demandée au client ;
// un morceau plus gros est refusé plutôt que d'être avalé.
uploadChunkMax = 8 << 20
)
// uploadsDir renvoie (en le créant) le dossier de dépôt, dans le workspace agent.
func uploadsDir() (string, error) {
@@ -194,15 +203,61 @@ func handleChatFile(w http.ResponseWriter, r *http.Request) {
type uploadReq struct {
Name string `json:"name"`
Data string `json:"data"` // base64 (accepte un data: URL complet)
Data string `json:"data"` // base64 d'UN morceau (accepte un data: URL complet)
// Envoi en plusieurs morceaux. Le premier appel n'a pas d'ID et reçoit celui
// que le serveur attribue ; les suivants le rappellent. `More` à false ferme
// le fichier. Un envoi en un seul morceau (More absent) reste valable.
ID string `json:"id"`
More bool `json:"more"`
}
// handleChatUpload écrit un fichier dans le workspace et renvoie son chemin
// relatif, celui que le client joindra au message (/api/chat/send, champ files).
// upSession = un envoi en cours, adossé à un fichier .part sur le disque.
//
// Rien n'est accumulé en mémoire : chaque morceau est décodé puis écrit tout de
// suite, et seul le descripteur reste ouvert. C'est ce qui permet de passer à
// 1 Go — la version d'avant gardait le fichier entier en RAM, deux fois (le
// base64 reçu et le binaire décodé), ce qui plafonnait l'envoi à quelques Mo
// utilisables sans faire gonfler le process.
type upSession struct {
f *os.File
name string
written int64
last time.Time
}
var (
upMu sync.Mutex
upSessions = map[string]*upSession{}
)
// upSessionTTL : au-delà, un envoi interrompu (onglet fermé, réseau coupé) est
// abandonné et son .part supprimé. Sans ça, un fichier à moitié transféré
// resterait ouvert et occuperait le disque indéfiniment.
const upSessionTTL = 10 * time.Minute
// sweepUploadSessions ferme et efface les envois abandonnés. Appelé à chaque
// nouveau morceau : pas de goroutine de ménage à faire vivre.
func sweepUploadSessions() {
now := time.Now()
for id, s := range upSessions {
if now.Sub(s.last) > upSessionTTL {
name := s.f.Name()
s.f.Close()
os.Remove(name)
delete(upSessions, id)
}
}
}
// handleChatUpload reçoit un fichier, en un ou plusieurs morceaux, et renvoie
// son chemin relatif — celui que le client joindra au message (/api/chat/send,
// champ files).
func handleChatUpload(w http.ResponseWriter, r *http.Request) {
var body uploadReq
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2*uploadMaxBytes)).Decode(&body); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": "fichier trop gros ou requête invalide"})
// Le plafond porte sur UN morceau, pas sur le fichier : c'est la seule borne
// qui compte pour la mémoire du process.
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2*uploadChunkMax)).Decode(&body); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": "morceau trop gros ou requête invalide"})
return
}
data := body.Data
@@ -218,29 +273,103 @@ func handleChatUpload(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 400, map[string]any{"ok": false, "error": "contenu illisible (base64 attendu)"})
return
}
if len(raw) == 0 {
upMu.Lock()
defer upMu.Unlock()
sweepUploadSessions()
s := upSessions[body.ID]
if s == nil {
if body.ID != "" {
// L'envoi a expiré ou le serveur a redémarré en cours de route : le dire,
// plutôt que de recommencer un fichier à partir de son milieu.
sendJSON(w, 409, map[string]any{"ok": false, "error": "envoi expiré — recommence le fichier"})
return
}
dir, err := uploadsDir()
if err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
f, err := os.CreateTemp(dir, ".upload-*.part")
if err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
s = &upSession{f: f, name: safeUploadName(body.Name)}
body.ID = filepath.Base(f.Name())
upSessions[body.ID] = s
}
s.last = time.Now()
abort := func(code int, msg string) {
name := s.f.Name()
s.f.Close()
os.Remove(name)
delete(upSessions, body.ID)
sendJSON(w, code, map[string]any{"ok": false, "error": msg})
}
if s.written+int64(len(raw)) > uploadMaxBytes {
abort(413, fmt.Sprintf("fichier trop gros (max %s)", humanBytes(uploadMaxBytes)))
return
}
if len(raw) > 0 {
if _, err := s.f.Write(raw); err != nil {
abort(500, err.Error())
return
}
s.written += int64(len(raw))
}
if body.More {
// Morceau intermédiaire : on rend l'ID pour la suite et l'avancement, qui
// alimente la barre de progression côté client.
sendJSON(w, 200, map[string]any{"ok": true, "id": body.ID, "received": s.written})
return
}
// Dernier morceau : on ferme et on donne au fichier son vrai nom.
delete(upSessions, body.ID)
part := s.f.Name()
if err := s.f.Close(); err != nil {
os.Remove(part)
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
if s.written == 0 {
os.Remove(part)
sendJSON(w, 400, map[string]any{"ok": false, "error": "fichier vide"})
return
}
if len(raw) > uploadMaxBytes {
sendJSON(w, 413, map[string]any{"ok": false, "error": fmt.Sprintf("fichier trop gros (max %s)", humanBytes(int64(uploadMaxBytes)))})
return
}
dir, err := uploadsDir()
if err != nil {
dest := uniqueUploadPath(filepath.Dir(part), s.name)
if err := os.Rename(part, dest); err != nil {
os.Remove(part)
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
dest := uniqueUploadPath(dir, safeUploadName(body.Name))
if err := os.WriteFile(dest, raw, 0o644); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
rel := "uploads/" + filepath.Base(dest)
sendJSON(w, 200, map[string]any{
"ok": true,
"path": rel, // ce que le modèle verra (résolu par resolveAgentPath)
"path": "uploads/" + filepath.Base(dest),
"abs": dest, // affiché à l'utilisateur, jamais renvoyé au serveur
"size": len(raw),
"size": s.written,
})
}
// cleanStaleUploadParts efface les .part laissés par un envoi que le process n'a
// pas pu terminer (arrêt du service, crash). Appelé au démarrage : les sessions
// vivent en mémoire, aucun de ces fichiers n'est reprenable.
func cleanStaleUploadParts() {
dir, err := uploadsDir()
if err != nil {
return
}
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range entries {
n := e.Name()
if !e.IsDir() && strings.HasPrefix(n, ".upload-") && strings.HasSuffix(n, ".part") {
os.Remove(filepath.Join(dir, n))
}
}
}
+69
View File
@@ -162,3 +162,72 @@ func TestExportFileNamesBothForms(t *testing.T) {
t.Fatalf("sans pièce jointe : %v", got)
}
}
// upload envoie un morceau et renvoie la réponse décodée.
func uploadChunk(t *testing.T, body map[string]any) (int, map[string]any) {
t.Helper()
b, _ := json.Marshal(body)
rec := httptest.NewRecorder()
handleChatUpload(rec, httptest.NewRequest("POST", "/api/chat/upload", strings.NewReader(string(b))))
var out map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &out)
return rec.Code, out
}
// Un fichier arrive en plusieurs morceaux et doit être reconstitué à l'octet
// près. C'est ce découpage qui permet le gigaoctet : sans lui, tout le fichier
// passait dans un seul corps JSON, gardé en mémoire des deux côtés.
func TestChatUploadChunked(t *testing.T) {
part1 := strings.Repeat("A", 5000)
part2 := strings.Repeat("B", 3000)
part3 := "fin"
code, resp := uploadChunk(t, map[string]any{
"name": "gros.bin", "data": base64.StdEncoding.EncodeToString([]byte(part1)), "more": true})
if code != 200 || resp["id"] == nil {
t.Fatalf("1er morceau : code %d, resp %+v", code, resp)
}
id := resp["id"].(string)
if got := resp["received"].(float64); got != 5000 {
t.Fatalf("avancement après 1er morceau = %v", got)
}
if code, resp = uploadChunk(t, map[string]any{
"id": id, "data": base64.StdEncoding.EncodeToString([]byte(part2)), "more": true}); code != 200 {
t.Fatalf("2e morceau : code %d, resp %+v", code, resp)
}
code, resp = uploadChunk(t, map[string]any{
"id": id, "data": base64.StdEncoding.EncodeToString([]byte(part3))})
if code != 200 || resp["ok"] != true {
t.Fatalf("dernier morceau : code %d, resp %+v", code, resp)
}
abs := resp["abs"].(string)
defer os.Remove(abs)
if resp["path"] != "uploads/gros.bin" {
t.Fatalf("chemin = %v", resp["path"])
}
got, err := os.ReadFile(abs)
if err != nil {
t.Fatal(err)
}
if string(got) != part1+part2+part3 {
t.Fatalf("reconstitution : %d octets, attendu %d", len(got), len(part1+part2+part3))
}
// Aucun .part ne doit traîner une fois l'envoi terminé.
dir, _ := uploadsDir()
entries, _ := os.ReadDir(dir)
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".part") {
t.Fatalf("fichier temporaire laissé : %s", e.Name())
}
}
}
// Un identifiant inconnu (envoi expiré, serveur redémarré) ne doit pas produire
// un fichier tronqué qui repart de son milieu.
func TestChatUploadUnknownSessionRefused(t *testing.T) {
code, resp := uploadChunk(t, map[string]any{
"id": "inconnu-1234.part", "data": base64.StdEncoding.EncodeToString([]byte("x"))})
if code != 409 {
t.Fatalf("code %d, attendu 409 — resp %+v", code, resp)
}
}